-
2026-09-01 —
clip_spanscan now be told the range that actually ships (file_utils/mesh_convert/_mesh_convert.py). The*entry is the source stack's zero, and the converter rebases a stack onto its first key — so the entry has to describe the frames the FILE holds, not the frames the SCENE holds. Fed a scene-wide key walk, a key authored before the exported range set an origin the file never had and slid every clip cut from that stack by the difference (measured on a production assembly: 33 frames, ~90 cm of apparent distortion with the geometry exact). New optionalstack_rangepins it; the entry is reported even when the pre-bake scene had no key there, because the bake writes one on every frame of the range. Callers that omit it are unchanged. Tests:test_the_whole_timeline_span_follows_the_exported_range,test_an_empty_scene_still_reports_the_exported_range.ExportVerifier.check_clips_vs_takescarried the SAME blind spot from the other side -- it read the whole-timeline clip's raw end frame and called a correctly rebased file short by exactly the export's start frame -- and now measures from the clip's published origin too (test_a_rebased_full_timeline_clip_is_measured_from_its_own_zero, withtest_a_rebased_clip_that_is_actually_short_still_failskeeping the gate sharp). -
2026-08-31 —
TaskFactorynow interleaves checks with tasks instead of running every task first (core_utils/task_factory.py). A failed check aborts the export, so every task that ran before it was work the abort threw away — and in the scene exporters that is minutes of texture conversion and per-frame baking for a file that is never written. A subclass can now declareCHECK_DEPENDENCIES = {check_name: (task_name, ...)}, and_schedulehoists each check to the earliest point where every task it reads has run (a check that depends on nothing enabled runs before the host is touched at all). Tasks are never reordered:TASK_ORDERencodes which task must see another's output and no dependency graph describes it, so only the checks move. Once a check fails the remaining tasks are skipped, and with them the checks that needed one — checks already decidable still run, so one pass still reports every failure the user can act on. Declaring nothing keeps the old order exactly, which is what blendertk's exporter still does._manage_contextgrew an optionalgateand now numbers tasks and checks in separate sequences (an interleaved list otherwise reported "Task #7/12" over a check). -
2026-08-31 —
ShotEditLedgertrimmed to what actually has callers (core_utils/engines/shots/shot_ledger.py).keep_curves,rename_curve,clear,owns_keyandkey_ownerwere written on symmetry rather than need and had no consumer in either DCC adapter (key_recordsalready hands a caller the owner alongside the time, and a store invalidation replaces the whole ledger throughfrom_dict). Removed the same day they were added, before anything could depend on them. -
2026-08-31 —
ShotEditLedger: the shot system now records what IT wrote on the animator's curves (core_utils/engines/shots/shot_ledger.py, new; wired ontoShotStore.edit_ledgerand itsto_dict/from_dict). The shot system makes two writes on curves it does not own — a stepped/CONSTANT hold on the last key before an inter-shot gap, and a sample on a shot bound so the shot's content is its own. Both are correct while the boundary that caused them is where it was; afterwards they are indistinguishable from hand-authored work and were left behind on every adjust. The ledger keys claims by(curve, time)(steps carry the tangent types the key had BEFORE, samples carry the(shot_id, edge)bound they serve), so a claim can be released exactly, and can be REMAPPED when the key moves — without which a rigid ripple strands every claim at the frame the key used to be on. A write the system did not make is never claimed and therefore never taken back: an already-stepped key is the animator's by definition. Persisted with the store because the writes it tracks persist with the scene; a payload written before the ledger existed loads as empty, and a record short of its payload is tolerated rather than failing the scene load. -
2026-08-31 —
IterUtils.find_flat_interior_indicesnow honors its documented contract (iter_utils/_iter_utils.py). The docstring always promised a flat segment is a run “whose values all fall within value_tolerance”, but the implementation tested ADJACENT diffs plus an endpoint-span guard — so a slow excursion that returns to its start (sub-tolerance per step, endpoints equal) read as ONE flat run while its interior wandered arbitrarily far, and every interior key was deleted. Being a plain value test it was unit-independent — degrees crept exactly like meters — which is how it survived the same-day unit-normalization fix in mayatk's optimizer and kept 1.5–3.15 cm of compounding joint drift in every VDATS wire-loom export (the _01 chains' gentle sway between holds is precisely this shape). The rewrite bands each run around its FIRST value — the documented semantic — which subsumes the old span guard; genuinely flat holds collapse exactly as before, and any key it removes is now reproducible from its boundary pair within tolerance by construction. Pinned by the newFlatInteriorContractTest(round-trip excursion, wholesale-collapse guards, hold/ramp regression, and a per-removal band-contract assert that the old implementation fails at 20× tolerance). -
2026-08-31 —
MeshConvert.TIMEOUT_SECONDS_PER_MBraised 3 → 10 s/MB. Second timeout incident in the field: a 173 MB assembly FBX got a 495 s budget and FBX2glTF was killed mid-conversion while a test suite shared the machine, discarding a finished export's GLB — exactly the "green on a quiet machine, red mid-workday" failure the size-derived budget exists to prevent, one size class below the first incident. The asymmetry argument only sharpens with margin (a generous budget merely delays reporting a genuine hang; a tight one discards a deliverable), so the per-MB rate now carries real contention headroom. Floor unchanged; explicit timeouts andNonebehave as before. Regression pinned in the existing scaling test. -
2026-08-31 — permanent FBX/GLB deliverable verification:
GlbReader,FbxFileandExportVerifier(file_utils/mesh_convert/). The productionized form of the harness that caught a shipped GLB placing a wire loom 7.5 cm from the plug it is constrained to — a defect no exporter log mentioned. Three read-only siblings of the editors:GlbReaderwraps the existingMeshConvert.GlbEditsession (one container parser in the ecosystem) and adds what no writer exposes — all-component-type accessor decode, LINEAR/STEP/CUBICSPLINE sampler evaluation at arbitrary times, and node WORLD-matrix composition (the row-form convention is documented and deliberately kept: it reproduced live-Maya positions to under 2 mm across a 2,480-node production assembly).FbxFileis a zero-dep binary-FBX reader (32-bit and 64-bit record layouts; array payloads skipped by default, so a 200 MB file parses sub-second) answering the census questions — objects by class, take names, connections.ExportVerifierruns prefix-discoveredcheck_*gates over a GLB and/or FBX — container, extensionsRequired⊆Used, basisu declaration + fallback, skin inverseBindMatrices, dangling channels, NaN/garbage animation scan, clip lengths vs the sidecar's declared takes (auto-found beside the file), FBX takes vs declared, GLB↔FBX clip/stack cross-check, and structural drift vs a baseline GLB — reporting PASS/WARN/FAIL/SKIP per gate (run().ok,summary(),to_json()), with the embedded-envelope pass delegated to the existingMeshConvert.verify_glbrather than duplicated. The NaN scan is two-tier by measurement, not caution: Python's (and many writers') min/max simply SKIP NaN, so a poisoned channel can carry clean stamped bounds — the free tier reads bounds,deep=Truedecodes the animation outputs themselves (never geometry), and the verifier's gate uses deep. CLI:python -m pythontk.file_utils.mesh_convert.export_verify ASSET.glb ASSET.fbx [--baseline old.glb] [--json]; a missing input makes its gates SKIP, never fail. Two traps found writing it, both pinned by tests: the runner's owncheck_nameshelper was discovered as a gate by its own prefix (renamedgate_names), and the test fixture's glTF omitted itsaccessorsarray, so six green-looking reader tests were exercising the None-fallback path. Its FIRST production run then caught a third trap and the gate now encodes it: FBX2glTF mints IBM-less bookkeeping skins nothing references (63 before, 100 after a hierarchy flatten on the measured assembly), socheck_glb_skinsfails only a skin a node actually references that lacks inverseBindMatrices and WARNs the stub count — rerun on the 148 MB deliverable: 10 PASS, 2 explained WARNs, 0 FAIL, exit 0. Self-critique added two more pinned behaviours: amatrixnode keeps its static matrix even when sampling args are supplied (glTF forbids animating one; the TRS fallback silently composed identity), and a corrupt sidecar degrades its gates to SKIP-with-reason instead of crashing the constructor — a verifier's inputs are exactly the files most likely to be broken.test_export_verify.py: 24 tests, byte-built GLB/FBX fixtures, no checked-in binaries. -
2026-08-30 — the live preview can publish a GLB that already exists (
net_utils/preview_server.py). NewPreviewBridge.publish_file(path): the one delivery shape with no host in it — nothing is selected, exported or converted, an authored.glbgoes straight to the page on the same server, port and tab a push already owns, so an outside asset and your own export alternate in one viewer rather than needing a second. Two rules it does not bend: the file is COPIED, never moved (it is the user's asset, not a scratch artifact the bridge minted), and it is served exactly as authored — no sidecar, no lightmap wiring, no texture re-encode, because those passes repair what a DCC export loses and a finished GLB has already answered them. A.gltfis refused with the reason rather than served: the server publishes one file into its root, so the sibling.binand textures would 404 and the page would show an empty scene with nothing to explain it. The publish tail every delivery shares — activate the script set, bump the version, decide whether a tab needs opening — came out ofPreviewDeliverer.deliverintoPreviewDeliverer.publishso the two entry points cannot answer those three questions differently (a second copy is exactly how a push and a publish end up disagreeing about whether an unticked script box turns a script off).test_preview_server+7. -
2026-08-30 — a fade over instanced geometry cloned its material once per instance, stranding all but the last (
file_utils/mesh_convert/glb_fades.py). Found auditing a delivered assembly: 18 fade clones for the 13 any primitive actually drew, and 13 of its 44KHR_animation_pointerchannels animating materials nothing rendered._isolatewalks every node in the faded subtree, and instanced parts share one mesh — so the walk reached the same primitives once per instance. The first visit repointed them at a fresh clone; the second read that clone as the source material, found it absent from the{source: clone}map, and cloned the clone — stranding the first with no primitive while still returning it to the caller, which writes one channel per returned material. A mesh is now repointed once per call; a mesh copied for an outsider is always a fresh index, so it can never be the one skipped. No visible defect in the shipped file (every faded surface still reached a live BLEND material), but each extra instance cost a material and a channel per clip.test_mesh_convert+2 — the instanced-inside case pinned to one clone, and the invariant behind the bookkeeping (no channel targets a material no primitive draws) asserted over both instancing shapes; both fail on the old code with the production symptom. -
2026-08-30 — a described ORM binding did not cover the lightmap clones made from it (
file_utils/mesh_convert/_mesh_convert.py). The lightmap pass makes one material per instance, so a base material becomes<base>~lm<N>— but the envelope'smetallic_roughnesssection names the base materials and was written before the clones existed.suspect_orm_materialsmatched those names literally, so every clone of a described material reported as an undescribed binding: on a production room, 46 notes from a single base material, which is the length that stops a note being read at all. A described material now covers its clones. The join token is a named constant (LIGHTMAP_CLONE_SUFFIX) used by both ends — the pass that writes the name and the check that has to recognise one — rather than an inline f-string on one side and nothing on the other; only~lmfollowed by digits marks a clone, since the token is legal in an authored name and stripping at the bare form would exempt an unrelated material from every finding.test_mesh_convert+2 (clones of a described base report nothing;covered~lmapis still reported). -
2026-08-30 — the Normals dial is held back behind a lookdev gate (
net_utils/preview_viewer.html). The dial works and its file side ships (normalTexture.scale, written byMeshConvert.set_glb_normal_scalethroughPOST /settings, all of it untouched here), but a lone slider does not earn a permanent seat in a control bar that has to survive a phone-sized viewport, and lookdev worth offering is a set — exposure, environment rotation, tone mapping — tuned together. So it is packaged rather than deleted: a#lookdevarea now holds it, the area follows its contents (a dial with nothing to do hides itself, and an empty chrome row never appears), and one flag —LOOKDEV_ENABLED, currentlyfalse— gates the whole thing; flipping it restores the control exactly as it was. Adding the next dial is markup inside#lookdev, a sync called fromsyncLookdev, and its element in that function'scontrolslist. The render is unchanged: the bake-relief shader patch and the value the GLB itself carries are both untouched, so a file saved at 1.8 still previews at 1.8. Tests:test_preview_viewer_live+1 — asserted on the fixture that would show the dial (baked, carrying a normal map) and on both elements, so the dial reporting visible inside a hidden area proves the gate is what closed it; mutation-proven by flipping the flag. -
2026-08-30 — a switched-off task announced itself as executed, and a switched-off
set_task staged a revert for a mutation it never made (core_utils/task_factory.py). Found while diagnosing a production export from its log:apply_declared_takesprinted "Executing Task #3/3" and "Completed in 0.000s" on a run where the task was passedFalseand never ran. A zero-argument task treats its value as a checkbox, and the executor answersTruewithout calling it — but the "Executing" line is printed BEFORE that decision, so the log says a task ran and found nothing to do where in truth it never ran. Those are different diagnoses, and telling them apart cost real time on the very investigation this was found in; the same log is what an artist reads to know what their export did. It now reportsSkipping Task #N/M: <name> (disabled)and prints no completion line. The second half is a latent correctness trap one line away: that unconditionalTruewas then stored as the task's captured original state, so a disabledset_xstagedrevert_x(True)— a value that was never a state of anything — to run after the pass. No shippedset_/revert_pair takes zero arguments (checked across both DCC packages), so nothing does this today; the guard now skips staging outright, since a task that never ran has no mutation to undo. The introspection both paths need is one_positional_counthelper feeding a named_task_is_disabledpredicate, so the dispatcher and the log cannot disagree about which shape of task a falsy value switches OFF versus passes THROUGH (0and""remain legitimate ARGUMENTS to a task that takes one).test_task_factory+4 (skipped-not-executed in the log; no revert for a disabledset_; and the pairing still fires for an ENABLED zero-argset_, so the guard cannot quietly disarm the real thing). -
2026-08-30 — a deliverable whose handoff declares shots but whose file carries no animations now says so, twice (
file_utils/mesh_convert/_mesh_convert.py). Found auditing a production deliverable: the export wrotedata_export.fbx_takesnaming 12 shots while the FBX itself had been written with animation disarmed (zeroAnimationStack/AnimationCurvein 70 MB — bake/takes off at write), so the GLB shipped a handoff promising 12 clips over an EMPTY animations array — and every existing check passed, because textures (34/34 digests), sidecar sections (4/4 landed) and envelope were all sound. A recipient reading the handoff plans for clips that do not exist, and in the viewer the sequence simply never moves, which reads as "animation broken" rather than "animation absent". Two guards, at the two places that each know both halves.apply_glb_clips— which until now returnedNonewithout a word when there was nothing to cut from — WARNS with the count and the cause when takes are declared against a file with no animations at all, so the export log says it at conversion time; andverify_glbFAILS the file for it, so a recipient's report says it after the fact. The clips pass's other silentNoneexits (no takes, no fps, no matching stack) keep their existing semantics — this fires only on the combination that is wrong in both deliverables at once.test_mesh_convert+2, both fixtures shaped from the production file. -
2026-08-30 — a by-name material repair reached only the LAST material carrying the name, and the preview applied its repairs after the fade pass had already cloned the materials; plus normal maps now actually show on baked geometry (
file_utils/mesh_convert/_mesh_convert.py,net_utils/preview_server.py,net_utils/preview_viewer.html). Reported as "the SCREENS_TEST_CMPTS material is where the texture rendering issue is occurring, but I don't think it is limited to that material alone", and measured on the pushed GLB:SCREENS_TEST_CMPTS_MATexisted three times,ITA_Extras_MATfifteen,MAT_VDATS_cabinetfour -- the fade pass clones a material per faded subtree and keeps its name, and FBX2glTF itself had emitted twoITA_Extras_MAT-- and in each set exactly ONE copy carried the sidecar'sorm_*packing while the rest, the eleven-primitive screen original included, kept the converter's ownao_met_rough_*map with roughness and metalness at 255 on every texel: a fully metallic, fully rough screen, which is what "as if roughness were missing" looks like. Two causes._match_glb_materialsresolved the sidecar's names through a{name: material}dict, so every writer it fronts (base colour, emissive, metallic-roughness, alpha mode) landed on whichever copy came last; it now pairs an entry with EVERY material carrying the name, andapply_scene_sidecarcounts the outcome by name so three repaired copies read "1 of 1", not "3 of 1". And the preview deliverer applied the envelope in a pass of its own AFTERfbx_to_glb, whose chain had already run the fade pass -- the same trap the lightmap clones fell into once, resolved then by reordering; the deliverer now hands the envelope to the conversion (sidecar=), where it lands ahead of the clone, and its pass reads the recorded outcome back (SIDECAR_APPLIED_KEY) rather than applying twice, which would write the authored alpha mode over the clones' BLEND and pop every fade. Verified by replaying the repair over the pushed GLB from the source-images directory: every copy of all three materials now samples itsorm_*map and the white packings are pruned. Separately, "the normals slider has no effect ... no normal effect at all" on the office room -- measured: normalScale 1 -> 4 moved ~1% of pixels by ~0.4/255, because a lightmap is direction-free irradiance that never consults the normal, so on a baked surface the map reached the picture only through the environment term the viewer dims to a quarter. The page now relieves the bake by the normal map (the lightmap texel scaled by how the perturbed normal faces the key-light direction relative to the flat one, half-Lambert wrapped, exactly 1 where the map is flat) throughonBeforeCompileon baked normal-mapped materials: the same dial now moves 13.8% of pixels by 3.5/255 at scale 1 and 36.7% by 8.7 at scale 4, compiles clean, andnormalTexture.scale-- the value the file carries -- does in the preview what it does under a runtime light. Two things that were wrong on the way there and are pinned: the hook receives the shader with#includedirectives UNEXPANDED, so a replace aimed at the chunk's line is a silent no-op (the directive is replaced with the edited chunk), andflatis a reserved word in GLSL. The in-conversion sidecar apply is now guarded like every sibling pass in that chain (a sidecar that blows up costs the repairs, never the conversion -- the preview's push rides through it now). Tests:test_mesh_convert+3 (every same-named material repaired, summary by name;TestFadeClonesInTheConversion-- with a sidecar and a fade through the real chain, the original takes the authored alpha mode and the clone stays BLEND, which only holds with the repair ahead of the clone; and a sidecar raising past its own guards still yields the GLB with the fade pass run),test_preview_server+2 (the envelope reaches the conversion; an already-applied envelope is read back, not re-applied),test_preview_viewer_live+1 (a baked normal-mapped material carries the relief hook and the load logs no shader error -- the check that catches both the unexpanded-include no-op and a reserved-word compile failure). -
2026-08-30 — WebP delivery was chroma-subsampling normal and ORM maps; the preview drew faded objects inside-out; and a normals dial now ships in the file (
file_utils/mesh_convert/_mesh_convert.py,net_utils/preview_viewer.html,net_utils/preview_server.py). Three reports from the same production assembly, three separate causes. "Materials no longer show in as good quality, as if missing roughness":optimize_glb_texturesin WebP mode -- the default web delivery -- handed every non-lightmap image to the lossy encoder, which is YUV 4:2:0. Chroma at half resolution and quantized is what a base colour was designed to survive; a normal map's X/Z and an ORM's occlusion/metalness sit in exactly those planes and were resampled as if the eye were judging them. Measured from the 4K sources through the 2K ceiling at the pass's own quality 85: base colour held 37.6 dB, normal X fell to 31.7 dB, ORM metalness to 30.8 dB -- flattened roughness and smeared normals, indistinguishable from the maps being absent. KTX2 mode always split by slot semantic (BASIS_BY_SEMANTIC); the same rule now reaches WebP (LOSSY_SAFE_SEMANTICS: lossy where the channels are colour, lossless everywhere else, the lightmap carve-out folded into it). Cost is bytes, not pixels -- on this asset ~5 MB to ~25 MB, in line with its KTX2 exports. "The fade effect inverted some normals": glTF has no depth field, so GLTFLoader derives one andalphaMode: BLENDbecomesdepthWrite: false-- right for a window, wrong for a solid object that merely fades, and since alphaMode belongs to the material rather than the clip, an object that fades in during one shot drew its far faces over its near ones in every other shot too (measured: 15 materials, alldepthWrite falseat full alpha). The viewer now derives depth from the alpha the mixer is driving, per mesh, on every mesh wearing a faded material: writes on while opaque, off mid-ramp so a half-transparent object cannot punch a hole in what is behind it. And the dial: on baked geometry a lightmap contributes irradiance with no direction in it, so the normal map survives only through the environment term the viewer deliberately dims -- a correct, flat-looking room. ANormalsslider beside the bake toggle (shown only when a baked material carries a normal map) drivesnormalScalelive, and Save writes it into the GLB asnormalTexture.scalethrough newMeshConvert.set_glb_normal_scale-- core glTF, honoured by every runtime, read back on the next load with nothing to re-apply -- reaching both the served copy and the file it was published from, and carried onto later pushes in the session (PreviewServer.apply_settings,POST /settings, allow-listed and same-origin like the close beacon). Also: a save is written before it is remembered (a failed write is a failed save, not a dial silently set); a re-push is stamped before its version is advertised (a poll in that gap got a torn GLB); and a dial that cannot be re-applied on a re-push is logged rather than raised, so the push itself is never lost. Tests:test_mesh_convert+11 (TestOptimizeGlbWebpSemantics5, mutation-proven against both the mode gate and the allow-list;TestSetGlbNormalScale6; the pre-existing lightmap-lossless test now binds its "source" image as base colour, because an image no slot samples has no semantic and is deliberately kept lossless),test_preview_server+13 (PreviewSettingsTestCase),test_preview_viewer_live+2 (depth follows alpha; the rule reaches every mesh sharing the material, asserted on the hook because the shared instance makes the value agree either way). -
2026-08-29 — every declared shot clip in a hand-off GLB was wrong on every frame, because Maya's take split is lossy; the clips are now cut from the whole-timeline stack instead (
file_utils/mesh_convert/glb_clips.py,_mesh_convert.py). Reported as "the split shots are not accurate and broken in places", and measured far worse than that: sampling every declared frame of every shot against what Maya itself evaluates there — 11 animated nodes × 2169 frames — Shot_1 through Shot_11 were out of tolerance on 100% of their frames, by up to 3.73 m of position and 90° of orientation, while the whole-timelineTake 001the same export retains was correct on all 2629 of its frames. That contrast is the whole diagnosis:FBXExportSplitAnimationIntoTakesrestricts each curve to the take's window before baking, so a curve with no key inside a shot contributes no channel at all to it and the node plays its rest pose — some arbitrary bind-time position — for the shot's entire duration. On a scene with 358 keys spread over 2635 frames that is not an edge case, it is the norm. Two more consequences of the same mechanism: a take is emitted spanning its authored KEYS rather than its declared window, so Shot_5 shipped 958-1015 for a window declared 915-1015 — 43 frames simply absent; and the retained stack ships beside the shots asTake 001, which is what made "the full sequence is labeled a take" read as one more shot in a list that is otherwise all shots. Newapply_glb_clipsreplaces all three: for each declared take, every source channel is windowed to the take, pinned at both ends and rebased so the take's first frame ist=0. Interior keys are copied, never resampled — the clip reproduces the source exactly whatever its key density, and a sparse STEP gate stays 3 keys instead of exploding to one per frame — with only the two boundary samples evaluated, which is what makes a shot whose window opens mid-curve open on the pose the DCC shows there. Re-measured on the same assembly: 0 frames out of tolerance, 0 coverage gaps, across all 12 shots and 23,798 node-frame comparisons, with the whole-timeline stack still clean as the control that proves the harness's own unit and axis conventions (glTF metres vs Maya centimetres — the first version of the report called every frame broken for that reason alone, and the control is what caught it). This runs on the deliverable, so it needs no scene, mutates nothing, and the exporter and the preview push get identical clips from the same pass. Each rebuilt clip now DECLARES its own origin inextras.zero_frame, which_clip_zeroprefers over re-deriving it — the clip was cut to its window, so it knows, and a second run is exact rather than merely harmless. The whole-timeline stack is kept (playing the sequence end to end, through the GAPS, is not reproducible by concatenating clips) but renamedFULL_SEQUENCEand moved LAST, so a consumer that ignores the manifest and opensanimations[0]now lands on the first shot. The boundary tolerance is a quarter frame, not an epsilon: the source's times come back through a float32 buffer where a key authored on frame 2500 reads 2500.0001, and an exact comparison pins a second key a ten-thousandth of a frame from the first — the production source is dense enough that the first version's inverted pin condition passed by luck, which is why the fixtures are deliberately sparse. Declines whole rather than half-rebuilding: a quantized or sparse source, an external buffer, or no published origin for the stack all leave the clips as exported, because a clip silently short one channel is the exact failure being repaired.test_mesh_convert+11. -
2026-08-30 — the fade now has ONE encoding, and the preview plays the deliverable's own bytes (
net_utils/preview_viewer.html,file_utils/mesh_convert/glb_fades.py,_mesh_convert.py). Asked as "what is the simplest and most elegant way to have our webxr preview and glb export ship with fades working?" — and the honest review of the entry below is that it shipped THREE: theKHR_animation_pointerchannels in the animations, a parallelanimation_web.fadesdata block carrying the same ramp in authoring frames, and a viewer that read the block and drovematerial.opacityby hand every frame through its ownzero_frame/fps arithmetic. Three things that could drift, two of them existing only because three.js does not implement the extension. Measured against the loader instead of assumed: GLTFLoader 0.169continues past a channel with notarget.node— so the clips arrive intact and merely minus their fades, and the missing piece is ~40 lines, not a runtime. The page now reads the file's OWN pointer channels back out (parser.getDependency('accessor', …)), resolves each/materials/N/…/baseColorFactorto the three.js material instances the loader built from glTF material N through the loader's ownassociationsmap (which it carries onto the variants it clones), and pushes aNumberKeyframeTrackon<mesh.uuid>.material.opacityinto the loadedAnimationClip. From then on the mixer plays a fade exactly the way it plays a translation — same playhead, scrub, pause — with no per-frame code, no frame arithmetic, no viewer-side material cloning and no side block. Thefadesblock is gone fromanimation_web; the handoffreadsnote now says what a runtime without the extension should do (bind each channel to its material's alpha, which is what the page does). Bound by uuid rather than name, for the same reason the gate resolves by index: glTF names are not unique and this assembly ships twovdat533. One real bug found by the review: the fade pass renamed its material clones<name>_fade, andapplyLightmapsbinds lightmaps by material NAME — so every faded material silently lost its lightmap in the preview. glTF does not require unique material names; the clone now keeps its source's. Also madeapply_glb_fadesidempotent (a second run cloned the clones and stacked a second channel on every material). The decisive test stripsextras.animation_webfrom the fixture entirely and asserts the fade still plays: with no side block to fall back on, a fade that plays can only have come from the animation itself. The fps-refusal viewer test is gone with the arithmetic it guarded — the refusal now happens once, at conversion, whereapply_glb_fadesdeclines a ramp it cannot place in time, and a file that reaches the page carries its times in seconds like every other channel.test_preview_viewer_live4 rewritten,test_mesh_convert+1 (idempotence; the clone-name rule joins the existing clone test), −2 (the block). -
2026-08-29 — authored opacity fades were inert in every consumer, and the deliverable had no way to say otherwise (
file_utils/mesh_convert/glb_fades.py,_mesh_convert.py,net_utils/preview_viewer.html). Asked as "the keyed opacity fade is just a custom attribute in maya — what is the best way to embed the instructions so that it fades in the webxr preview and exports in the glb in a way that … wires up?". Two separate things were wrong. (1) The fade could not be seen anywhere, including in Maya.opacityin the recommended attribute mode drives nothing; the viewport shows the booleanvisibilitymirrored from it, and that mirror isopacity > 0evaluated AT THE KEYS — so a fade-in keyed 0 at frame 8 and 1 at frame 23 mirrors to visibility 0 at 8 and 1 at 23, and a stepped boolean holds 0 across the whole ramp. The object is therefore absent for the entire fade-in and fully opaque for the entire fade-out, and every "fade" in the pipeline has always arrived as a pop. New_presence_keysgates a track carrying a real ramp on the ramp instead of on the mirror, so the object is there to BE faded; a track without one keeps the mirrored boolean byte-for-byte. It emits an explicit "absent" key on the ramp's own first frame, because a stepped track is read by holding its first key BACKWARDS and a rising ramp that says "present" would otherwise make the node present for every shot before the one it fades into — measured at seven nodes doing exactly that before the fix. (2) The file said the fade was unplayable. Newapply_glb_fadeswrites each ramp as aKHR_animation_pointerchannel targeting/materials/N/pbrMetallicRoughness/baseColorFactor— the Khronos answer for animating a property glTF has no channel for — declared inextensionsUsedand neverextensionsRequired, so a viewer without it still loads the file and one with it fades having been told nothing. Materials are cloned per faded subtree and switched toalphaMode BLEND, because a material is shared by whatever samples it (oneMAT_OFFICE_ENVcovers 46 meshes) and fading the shared instance dissolves half the room; a mesh shared with a node OUTSIDE the fade is deep-copied first, which costs no binary because a glTF mesh is JSON referencing the same accessors. The whole VEC4 is animated holding the material's own RGB, since glTF has no pointer to a single component. The preview's own playback stays, now documented as the polyfill it is — three.js does not implement the extension (re-checked through 0.180) — so both describe one ramp and the polyfill is deletable the day three.js ships support. Ordering is load-bearing in both directions: the gate has to run first so a fading node is present, and the fade has to run before the manifest so a shot whose only content is a fade is not reported as an empty clip. One bug found only by measuring against the scene:_windowrebases onto the window it cut, which is right for a clip BUILT to that window and wrong for one that already has a different zero — the whole-timeline clip, whose origin is the timeline's — so the ramp arrived 7 frames early there; the regression test for it is mutation-proven. Verified end to end: 7 faded nodes, alpha compared against Maya's ownopacityattribute at every frame of every shot, 0 mismatches. A second defect was caught by self-critique rather than by a test, and its test is mutation-proven: the material isolation ran BEFORE the pass knew whether any clip would carry a channel, so a ramp that fades entirely between two shots switched a subtree toalphaMode BLEND-- which sorts differently from opaque -- in exchange for no animation at all. Only observable on the open-edit call the conversion actually makes (given a path the pass never marks the file dirty on that exit), which is what the first version of the test missed.test_mesh_convert+11. -
2026-08-29 — the shot planner could not say which gaps a plan CHANGES, so nothing could retime their content (
core_utils/engines/shots/shot_plan.py). The pure half of the gap-resize repair (the Maya half is in mayatk's entry). A shot move is rigid — it keeps its duration, so its content travels with it unchanged — and a gap is the exact opposite: changing its width is what a respace IS, so content living in one has nowhere to be carried to. NewGapRetimeandShotPlanner.plan_gap_retimesderive, from a finishedMovePlan, every gap whose width changes, with the scale to apply and how far its left edge (the preceding shot's end) travels. Derived from a plan rather than built alongside one, so every constructor — respace, ripple, slide, reorder — gets the same answer from the same rule, and a pure translation correctly yields nothing. Writing the test for that turned up a fact worth pinning: a downstream ripple excludes its pivot by design, so the gap between the pivot and the first shot that moved is stretched by the full delta — not an edge case, it is what a ripple always does.test_shots_core+7. -
2026-08-29 — the published alpha ramps could not actually be used, because nothing in the file said where a clip's
t=0sits (file_utils/mesh_convert/_mesh_convert.py,net_utils/preview_viewer.html). Follow-up to the keyed-visibility entry below, and a correction to it: that work shipped the authored fades as data onanimation_web.fadeswith a note telling a consumer to "drive the node's material alpha from these keys" — but the keys are AUTHORING frames and the playhead is clip-local seconds, and the file published no way to convert between them.start_frameis the declared shot window and is the wrong answer:_clip_zerohad already established (and the entry below already says) that a take is rebased onto its first authored KEY, so the two differ by the take's lead-in — re-measured on the same production assembly at +1, +8, +8, +8 and +43 frames across Shot_1..Shot_5, i.e. up to 1.43s. So the one instruction the deliverable gave for the one thing it could not play was not followable, and every consumer that tried would have placed the ramp wrong. Each clip now carrieszero_frame, the authored frame itst=0lands on, and the note names it. This is a plain correctness fix beyond the fades: the block's second documented purpose is "where a clip sits on the timeline… for a consumer rebuilding the sequence", andoffset— the field it offered for that — is the DECLARED start, so a rebuilt sequence misplaced every clip with a lead-in. Both fields now ship, with the docstring saying which one converts a playhead. The span-selection rule (_clip_span_for) and the take-window read (_take_windows) are extracted rather than copied into the second caller: they decide where a clip's origin is, and two copies that disagree would put a node's visibility switch and the clip's published origin at different instants in the same file — a mutation test confirms both readers now depend on the one rule._clip_zerogrewverify=Falsefor the manifest pass: the gate holds its final state to the end of the shot's window, which legitimately makes the clip longer than the keys the producer measured, so re-checking after the gates are written reported that growth as a misalignment on every clip it had just placed correctly (12 spurious warnings on a correct file — caught and pinned before it shipped). The preview now plays the fades, which is what makes it faithful to the deliverable rather than to a subset of it: glTF animates only translation/rotation/scale/weights andKHR_animation_pointeris still unimplemented in three.js (re-checked against 0.180, not just the pinned 0.169 — so this is not a wait-for-the-next-release situation), so the page reads the published ramps and drives material alpha itself. Materials are cloned per faded subtree, never driven in place: glTF hands every primitive using a material the same instance and this assembly shares one across 46 meshes, so fading the shared instance would dissolve half the room along with the prop.transparentis set once at load rather than toggled per frame (flipping it recompiles the shader, and a fade crosses that boundary twice), whiledepthWritefollows the alpha so a fully opaque surface does not sort against its own back faces. Nodes are resolved through GLTFLoader's own association map rather than by name: glTF does not require unique node names, this assembly ships twovdat533, and the loader UNIQUIFIES the duplicates (vdat533_1) — so bothgetObjectByNameand a name comparison drive the first and silently leave the rest at full alpha while their gate switches, which is the same "broken on some objects and not others" the gate itself was written to avoid. Caught by the test for it, against the first version of this fix. Verified end-to-end against the production deliverable: 819 of 819 samples — 7 faded nodes × 9 playhead positions × 13 clips — match the DCC's authored curve, and the mutation that usesstart_frameinstead fails 7 of them, worst case an object that should be fully faded out reading fully opaque. The page also REFUSES a ramp whose block carries no frame rate rather than assuming one: frames without a rate have no time to be placed at, a guess plays every ramp at the wrong speed (subtly wrong beats plainly unfinished, which is the worse failure), and it is the same callapply_glb_visibilityalready makes on the same missing value. The fade count joins the lightmapped fraction and the clip count in the stats line, for the reason both of those are stated — it is invisible from the render, and a ramp that is not being driven looks exactly like an object that simply pops, which is how these went unnoticed as data nobody played.test_mesh_convert+6,test_preview_viewer_live+4 (every one proven non-vacuous by mutation). -
2026-08-29 —
resolve_ktx2_encoder(required=True, auto_install=True)could returnNone, and the panel-side 'ensure it, offering the install' dance had no owner (img_utils/_img_utils.py). Two things, found writing the WebXR preview's Texture Format row. (1) Theauto_installbranch endedreturn Ktx2Encoder(toktx=toktx) if toktx else None— so a falsy path fromresolve_toktxfell through toNoneunderrequired=True, which is a promise of a usable encoder or an exception. The sibling branch three lines below already spells out that exact reasoning ('relying on the delegate to always raise falls through to return None') and raises unconditionally; this branch never got the guard. The delegate does raise today, so this was latent — but the failure it hands a caller is aNonethat only surfaces much later inside the encode, naming nothing about a missing tool. Same backstop now, same message. (2) NewImgUtils.ensure_ktx2_encoder(prompt=...)— returns thetoktxpath it INSTALLED,Nonewhen an encoder was already there, raises the fix-shapedFileNotFoundErrorotherwise. It is the panel-side counterpart ofresolve_ktx2_encoder: a user who picks KTX2 in a UI must be offered the install, never handed a URL and an abort, and every such panel was writing the same four steps — probe, announce, resolve with consent, re-discover the binary to report it. Maya's Scene Exporter and Blender's had it; the WebXR preview would have been the third copy, and by then the re-discovery had no owner. Returning the path rather than a bool is what collapses the callers'missingflag, which had to be carried across their own try/except. (3) The path it returns comes off the encoder the resolver just BOUND, via a new read-onlyKtx2Encoder.toktxproperty — not a secondresolve_toktx()pass, which is the re-discovery the resolver's own comment says it avoids because an install can write a catalog entry a fresh discovery misses. The property also replaces the inlineself.resolve_toktx(required=True) if self._toktx is None else self._toktxinencode, so "the binary this encoder will actually run" has one definition instead of an idiom plus a private reach.test_img+3. -
2026-08-29 — the preview bridge's scope was a boolean, so the third scope every other hand-off offers was unreachable (
net_utils/preview_server.py,core_utils/app_handoff.py).PreviewBridge.push(whole_scene=bool)could express selection and whole scene and nothing else, whileuitk.bridge.Parameters.scope_spec— the one vocabulary the Blender/Unity/Marmoset/Substance/Rizom bridges and the Scene Exporter all speak — declares three:selected/all/visible. The missing one is the one that matters most here, because_scene_objectsis visibility-blind by construction (DAG roots minus startup cameras; the current scene's objects in Blender), so Entire Scene pushed hidden variants through the slowest stage of the pipeline — conversion is 91% of a push and its cost tracks payload size. Newpush(scope=...)and publicscope_objects(scope);whole_scenestays one release as a deprecated alias, andFalseis IGNORED rather than forced to"selected"so it cannot override an explicit scope passed beside it.scope_objectsis public because the caller needs the answer — a panel that pushes blind cannot tell "nothing selected" from "the scene is empty" from "the export failed", and the first two are the user's own next action. An unknown scope resolves to the selection, never to the scene: a scope must not silently WIDEN a push.scopealso travels to the exporter as theSCOPEparam (setdefault, so a caller who resolved the objects themselves outranks it), which is what finally reachesBlenderExportMixin._produce'sdescendbranch — written for this and, until now, dead on the preview path, so a Visible Only push would have re-added the hidden children of a visible parent and defeated its own scope. NewHandoffBridge._visible_objectshook sits beside_scene_objects: both are the same kind of host read — the two scopes that widen past the selection — and split apart, the visible one had already grown a second copy in each DCC's bridge-slots base while the whole-scene one stayed here.Nonefrom either falls back to the selection, never to an empty set, which would report a populated scene as nothing to push. -
2026-08-29 — shots: one rule decides which shot owns a sample two contiguous shots share (
core_utils/engines/shots/shot_plan.py). A shot spans the samplesstart..end, so at gap 0 shot N's closing sample and shot N+1's opening sample are the SAME frame — and the code answered who owned it three different ways (the plan's half-open envelope gave it to the following shot, the sequencer's inclusive window and_owner_rangesto the preceding one, membership to whoever listed the object). Measured consequences, all reproduced in mayapy: a key on the boundary could move with NEITHER shot and drift a gap-width per cycle, two keys could be stacked a fraction of a frame apart, and an unowned key could change hands on a round trip. Settled on the fencepost rule — the shared sample belongs to the shot that CLOSES on it — chosen by tracing the CONSUMERS rather than DCC convention:to_export_viewpublishesfbx_takesas(start, end)straight into inclusive FBX bake ranges, the manifest placesend = start + duration, and audio off-keys land onend, so an N-frame shot is N+1 samples and its last one is the next shot's first. (An earlierend + 1proposal was rejected on that evidence: it would have baked every take one sample short.)_envelope_fornow returns two closure flags with the bounds, andShotPlanner.in_windowis the single membership predicate — together they PARTITION the timeline, so no sample is in two windows and none falls between. NewShotPlanner.boundary_splits/key_collisions/envelope_for/ShotBoundaryConflictlet a DCC reconcile a shared sample being pulled apart or merged.objects_to_adoptdrops itsownersexemption (added hours earlier the same day, unreleased): it existed only to patch the double-claim the partition now makes unwritable.ShotPlanner.move_windowsandplan_pivot_movecomplete the set: both DCC single-shot movers now get their envelope and one-move plan from the engine instead of re-deriving them, which is how the two key movers drifted apart in the first place. 3789 tests pass, +27 new for the window, splits, collisions, pivot plans and window tuples. -
2026-08-29 — the cv2-less
fill_empty_texelspaid one full-image pass per texel of distance (img_utils/_img_utils.py). Blender's Python ships no cv2, so every lightmap heal and atlas assembly there took thedilate_image iterations=-1fallback, whose cost is set by the map's FARTHEST background texel: measured 1.8 s per 1024 per-object map (48% of a Blender bake loop), 3.6 s at 74% background, quadrupling per resolution doubling. The fallback is now_fill_pyramid: the ring next to content is neighbour-averaged exactly as before, and the far field takes 2x2 valid-only pooled averages from an image pyramid (each level filled with the same ring, leftovers from the next coarser level) -- O(log n) passes, 0.24 s on the same map, 15x. A far texel feeds nothing but coarse mips, where a pooled average is what the GPU computes anyway; the cv2 path (exact nearest) is unchanged.dilate_imagegainsreturn_mask=True->(image, mask)so a caller continuing the fill knows where the ring stopped. -
2026-08-29 — keyed visibility did not survive the glTF hop, and it took three separate-looking bugs with it (
file_utils/mesh_convert/_mesh_convert.py). Reported against a production assembly as "half my shots arrive empty, the keyed visibility (or fade) is not intact, and some of the animation is broken" — one cause. glTF animates translation, rotation, scale and morph weights, and visibility is none of them, so an FBX'sVisibilitycurves (which the FBX carries perfectly well, and which Unity reads natively) are dropped in the conversion without a word. Measured on the scene: of its 66 animation curves, 27 were translate and 25 rotate — and 7 were visibility and 7 opacity, i.e. the entire vis/fade half of the authoring was discarded.Shot_1andShot_11contain nothing but visibility, so they converted to named clips with zero channels; the shots that mix both arrived missing their vis component; and an object switched off in shot 5 was still standing there in shot 7, which reads as broken animation rather than as a missing channel. Newapply_glb_visibilityrebuilds them as STEP-interpolatedscalechannels driving each gated node between its authored scale and zero — a zero-scale node collapses to a point and rasterizes nothing, which is the established glTF idiom for boolean visibility precisely because it needs no extension, so the deliverable behaves the same in a viewer nobody here controls. Three things the naive version gets wrong, all pinned by tests. (1) The held value. A clip whose window contains no visibility key at all is not "nothing to write" — it is the state held into it, which is the whole of the shot-7 symptom. (2) The clip's zero is not the take's start. Measured against FBX2glTF 0.13.1 across all 10 populated clips: a take is emitted spanning its authored KEYS and rebased onto the first of them, and the converter counts the visibility keys when sizing the take even though it emits no channel for them (Shot_4spans 680-775 where 775 is a visibility key;Shot_5opens at 915 and zeroes at 958). A gate placed against the window start drifts by the lead-in — 43 frames onShot_5. The producer publishes that span asclip_spanand_clip_zeroVERIFIES it against the clip in the file, warning with the discrepancy when the export set it was computed against is not the one that shipped. (3) Strictly increasing times. Clamping a key authored before the clip's zero is exactly what ties two keys to one instant, which glTF forbids;_strictly_increasingcollapses them, later value winning. A node the clip already scales is left alone and named in a warning rather than given a second channel on the same target, which is undefined. Runs BEFOREapply_glb_animations, so a shot whose only content is visibility is no longer reported empty nor passed over as the file's default clip. The authored fade cannot come with it, and that is not a shortcut: animating a material's alpha needsKHR_animation_pointer, which three.js does not implement (checked against r169'sGLTFLoader, the renderer this pipeline targets and the overwhelmingly likely consumer), so an alpha ramp would be silently ignored by the very viewers it is for. The ramp ships as data onanimation_web.fadeswith the note saying how to drive it, and the presence channel steps exactly where the DCC's own playback steps._authored_fadespublishes only ramps that actually ramp — a mirrored pair of keys on one frame is a cut, and calling it a fade invites a consumer to rebuild something that was never authored. New_append_bin_viewsis now the one place bytes are added to a GLB's buffer, extracted from_relocate_embedded_images(append-only, 4-byte aligned, refusing a GLB whose buffer 0 is external) and shared with this writer, which appends every clip's samples in ONE buffer growth rather than one per clip. The channel's PRODUCER side is single-sourced here too, for the same reasonbuild_scene_sidecarowns the sidecar envelope and for one more: mayatk and blendertk cannot import each other, so a schema each writes by hand is a schema that forks. Newbuild_visibility_tracks(the versioned envelope, returningNoneas the producers' signal to CLEAR rather than stamp an empty channel) andclip_spans(per take, the first and last authored frame in its window) leave each toolkit only the part that needs a scene — collecting the key times. Every entry read out of the channel goes through one_numeric_pairsshape filter: it is JSON a producer wrote, decoded from a string attribute on a node in the deliverable, so a string where a number belongs must degrade to "no tracks" rather than raise out of a pass whose whole contract is that it degrades — the same rule_animation_spanalready applies to accessor bounds. Also fixed a pre-existing invalidity this work exposed:verify_glbnow FAILS a file whose animation carries no channels or samplers. glTF makes bothminItems: 1, and a take split emits a named AnimStack for a shot whose content is entirely visibility which the converter then writes out empty — so the production deliverable shipped three animations that a strict reader is entitled to reject, and nothing said so. The gate fills them, which is the structural half of the repair.test_mesh_convert+23. -
2026-08-29 — new
ShotPlanner.objects_to_adopt: the shared rule for what a moving shot may carry (core_utils/engines/shots/shot_plan.py). A mover shifts a shot's OWN object list within a window, so anything keyed in that window but missing from the list is left behind — the shot moves and part of its animation does not (see mayatk, where one "resize all gaps" stranded 146 keys on a production scene). Membership goes stale for ordinary, invisible reasons, so the window has to be consulted; but claiming purely on time steals keys at a shared boundary, where a resized shot's last key sits exactly on the next shot's start and would be moved twice. The rule — adopt only a key that no OTHER shot already owning the object covers — landed in mayatk and blendertk as near-identical copies whose divergence would have been silent; it now lives here once, taking plain dicts ({object: [times]},{object: [(start, end)]}) so each toolkit keeps only its own discovery.half_openselects the plan path's[start, next.start)envelope or an inclusive[start, end]window. 10 new tests. -
2026-08-29 —
apply_glb_lightmapscould bind an atlas from a DIFFERENT bake, silently (file_utils/mesh_convert/_mesh_convert.py). The applier locates each map by joining its basename against[manifest.dir, *search_dirs]. A basename is not an identity, andsearch_dirsis the host's live texture folders — which on a real project hold the atlas of that name from the LAST bake. So whenever the manifest carried nodir(see mayatk's entry: it was published only when every marker in the scene agreed on one folder), the first hit won and the deliverable paired THIS bake's rects with THAT bake's pixels. Measured on the production room: 46 objects bound a 512px atlas dated 17 days earlier, byte-identical to the stale file, while the fresh 1024px one sat in the folder the markers named. Two changes, both narrow. (1) The manifest's own folders come first and now include the publisher's newdirslist, so a scene with maps in several folders still steers the lookup. (2) A map resolved OUTSIDE every folder the manifest names is now a WARNING naming the file it bound and what that means — legitimate when maps have simply moved, which is why it is not a refusal, but it was the one thing that could have made this visible and it printed nothing. Reported once per map, not per object. Both hint keys also joinedLOCATE_HINT_KEYS, the set the two path scrubs strip on the way out:dirscarries the same absolute authoring directories asdir, and the existing scrub keyed ondiralone — so the new key would have shipped the artist's drive layout in every deliverable.test_mesh_convert+4 (hint precedence oversearch_dirs, a plural-only hint steering the lookup, a non-listdirsignored rather than splatted into one bogus directory per character, and both scrubs covering the plural key). -
2026-08-29 — a no-op shot edit destroyed the redo branch (
core_utils/engines/shots/shot_model.py).push_boundary_snapshotclears the redo side — correct, a real edit invalidates it — but several paths push UP FRONT and only then discover the edit did nothing (a clip drag that scaled no curve, a trim with zero delta, an insert that raised, a key delete whose everycutKeyfailed on a locked/connected attr on a referenced asset).discard_boundary_snapshotpopped the undo entry but could not put the redo branch back, so undoing an edit and then bumping anything that turned out to be a no-op silently cost the user their redo, with nothing on screen to explain it. The push now stashes the branch it clears and the discard restores it, making discard a true inverse of the push before it; anything that legitimately consumes the redo branch (restore, redo, ledger clear) invalidates the stash, so a single slot is enough. 18 call sites across mayatk and blendertk were affected. 6 new tests. -
2026-08-29 — the WebXR preview and the scene exporters published two different deliverables from one scene, and neither said so (
file_utils/mesh_convert/_mesh_convert.py,net_utils/preview_server.py). Measured by running both legs against the same production assembly in the same Maya session, comparing 24 observable properties of the resulting GLBs: geometry, materials, lightmaps and sidecar matched exactly (1925 nodes over 537 instanced meshes, 47/47 lightmaps bound, identical sidecar sections), and the textures did not — the preview published 8.71 MB of WebP at ≤2048 px and the exporter published 280.13 MB of full-resolution PNG. Setting the exporter's dials to WebP gave 22.06 MB, still 2.5x, because its size ceiling resolved from an absent template budget to "never resample" — so the old defaults could not reach the preview's output at all. An artist approves the first and hands a developer the second. The cause was that "finished for the web" existed in neither place: the preview named a container and letmax_sizefall through tooptimize_glb_textures' own signature default (so the resolution a deliverable is approved at was set two packages away, invisible to the exporters that would have to agree with it), while the exporters read panel dials whose unset state meant no pass at all. NewMeshConvert.web_delivery_texture_params(image_format=None, max_size=None)is the one definition,WEB_DELIVERY_MAX_SIZE/WEB_DELIVERY_FORMATthe constants behind it, andoptimize_glb_textures' own defaults now read from them so the two cannot drift while both exist. Both parameters distinguish unspecified (None→ the policy) from chosen (0→ keep every pixel), because the callers pass values resolved from UI dials whose "unset" is falsy and a falsy default reaching the optimizer stops inheriting and starts meaning something. Deliberately NOT coveringktx2_fallback: it answers "must this open in a stock glTF importer?", a property of the CONSUMER — the preview streams to a page wiringKTX2Loaderand saysFalse, an exporter handing over an asset that must also open in Blender or Unreal saysTrue, and a shared default would silently make one of them wrong. Verified on the real scene afterwards: the exporter published 8.63 MB, 537 meshes, 28 WebP images, 47 lightmaps.test_mesh_convert+5. -
2026-08-29 — byte-identical images shipped twice, and the texture pass paid to encode both copies (
file_utils/mesh_convert/_mesh_convert.py). FBX2glTF embeds per MATERIAL, so two DCC materials wiring one texture file arrive as two images with identical bytes — and glTF has no reason to keep both, since a texture names an image by index and any number may name the same one. The session's existingimage_digestsdedupe could not reach this: it is write-side, stopping a WRITER from appending a payload already embedded, and the converter's pair is in the file before any writer runs. Measured on a production assembly, 6 duplicate images and 10.2 MB of BIN payload at full resolution — and, because the collapse now happens beforeoptimize_glb_textures, six decodes, resizes and re-encodes that bought nothing (in the delivered file the same duplicates were still worth 154.7 KB of 5.9 MB). Newdedupe_glb_imagesis content-addressed, never name-addressed, for the same reasonimage_digestsis: the copies arrive by different routes and their names are the one thing that may lie in either direction — two different maps can share a name, and one map can carry two. It rebinds every texture (coresourceplus each entry ofTEXTURE_CONTAINER_EXTENSIONS) to the survivor and then delegates the reclaim toprune_glb_unreferenced_textures, which already owns index remapping and BIN repacking, so this pass only decides WHAT is redundant. Textures themselves are kept — two textures sampling one image may still differ by sampler, and collapsing them is a separate question. Run fromfbx_to_glbbefore every pass that reads or rewrites images, because it RENUMBERS them: the sidecar's texture map and the optimizer's per-image work both describe indices. It asks the prune's own question first: that prune bails whole on a file whose images are referenced from outside the material tree, or whose bufferViews sit outside the embedded BIN, and a rebind into either would leave payload nothing can ever reach — worse than the duplication it set out to remove — while reporting that it had removed nothing. The two refusals are now one_image_prune_refusalpredicate both read, rather than a condition the second caller had to remember. Writing this also turned up a third hand-written copy of the("KHR_texture_basisu", "EXT_texture_webp")pair (inGlbEdit.image_for_texture, which resolves a binding, besideTEXTURE_CONTAINER_EXTENSIONS, which owns the declarations); all three now read the one constant, whose order is documented as the resolution precedence the resolver depends on.test_mesh_convert+6. -
2026-08-29 — the preview left every FBX it had already consumed on disk (
file_utils/temp_artifacts.py,net_utils/preview_server.py). A hand-off bridge's payload store isdetachedbecause the target application reads the FBX after the call returns — there is no completion signal, so nothing may delete, and stale payloads are reclaimed by an age-gated sweep instead. The WebXR preview is the one bridge that does not fit: it converts the FBX to GLB and publishes it inside a single blocking call, after which nothing will ever read the payload again. Left to the 7-day gate it accumulated — measured on this machine, 20 files and 3.1 GB, at 324 MB per push of a production assembly. NewTempArtifacts.release(path)is the counterpart ofregister: it deletes one tracked path whatever the policy, and removes ONLY what the store minted or adopted, returning False rather than raising otherwise. That ownership guarantee is the load-bearing part — the caller is a strategy object holding a path it did not allocate, so aPreviewDeliverermounted on a bridge whose producer returns a durable file must not be able to delete it, and an ownership test the caller has to remember is one it can forget. It untracks only on SUCCESS, so a path another process still holds open stays in the ledger forcleanupto retry rather than being quietly demoted to a leak only the age sweep can reach.HandoffBridge._release_payloadpairs it with_make_payload_pathso the store stays the bridge's business and a deliverer — a strategy holding a path it did not create — cannot reach past it, and it takes the carrier's own sidecars (PAYLOAD_SIDECARS) with the payload: Maya's FBX plugin writes<stem>.fbmbeside an embedded-media export holding the textures the FBX embedded EXTRACTED AGAIN, so the first cut of this fix reclaimed only about half of what a push cost — measured on the production assembly, the 324 MB payload went and a 314 MB.fbmstayed. The deliverer releases the payload as soon as the GLB exists (before the passes, so peak disk is one deliverable rather than two) and KEEPS it on a failed conversion, where nothing consumed it and it is still evidence.test_temp_artifacts+5,test_preview_server+3. -
2026-08-29 — the sidecar summary counted scene materials that were never in the export as failed repairs (
file_utils/mesh_convert/_mesh_convert.py).SceneState.readdescribes the SCENE, so its sections name every material the scene holds — reference materials, ID materials, anything on geometry that did not export — while the GLB carries the exported subset. Using the envelope's own length as the denominator made a correct production deliverable reportbase_color: 10 of 23: 13 of the 23 named materials (REF_*,Labels_ID,standardSurface1, …) were not in the file at all, and an artist reading that sees 13 failures in the thing they are about to ship. Same distinctionapply_glb_lightmapsalready draws without_of_scope, now drawn here: the denominator is the entries naming a material this GLB HAS, with a" (13 not in this export)"clause when the envelope reached further. Deliberately conservative in two directions. A section matching NOTHING keeps the old0 of N matchedwording and its loud warning, because "every name is out of scope" and "every name is misspelled" are the same observation from inside the file and one of them is a defect; the applier's warning names the entries and lists the GLB's materials, which is what separates them. And a section where MORE landed than the exact-name scope allows falls back to the plain count rather than print "12 of 10" — every shipped applier matches on the name alone, but one that resolved namespace-tolerantly would break the model, and a number that cannot be true is worse than a coarse one._sidecar_section_scopelikewise returnsNonefor a section that is not a material-keyed map, so a future entry in the applier registry keyed on something else falls back rather than reporting all of itself as out of scope.test_mesh_convert+5. -
2026-08-29 — the viewer's lighting policy is now asserted by RUNNING it, closing a 17-day-old testing gap (
test/test_preview_viewer_live.py). Every assertion aboutapplyLightmaps/applyLighting/ theenvMapIntensityopt-out / thedisposeModeltexture-sparing guard wasassertIn("<a literal line of JS>", page). That pins the SPELLING, not the behaviour — and the cost was measured: the key-light gate shipped INVERTED for part of 2026-08-12 (asking whether the model was fully baked instead of whether anything was), every partly-baked room rendered blown out, the suite stayed green because it pinned the inverted line verbatim, and it was reported as a baker regression because the extra light is added downstream of the EXR. Six new tests drive the real page over a realPreviewServerin headless Edge against a fixture built by the REALapply_glb_lightmaps(hand-writingextras.lightmap_webwould test the page against a fixture rather than against the pipeline, which is the one thing these assertions exist to check) and read the liveTHREEobjects: the bake is rebound fromocclusionTexturetolightMapand the carrier slot released, it samples TEXCOORD_1 in sRGB, the HDR divisor arrives aslightMapIntensity, the key light goes off for a baked scene and stays on for an unbaked one, a baked material keeps a REDUCED environment (a bake carries diffuse and no specular, so removing it outright leaves every metal flat), and — the case only a second push can reach — the model still renders lit after a swap, which is whatdisposeModelsparing the shared environment map buys. The fixture asserts its own bind before any of them run: a manifest missingversionreads as newer than the reader, binds nothing, and would have made all six pass vacuously — which is exactly what happened on the first run. -
2026-08-29 — one unvalidated ORM produced 46 identical lines in the recipient's report (
file_utils/mesh_convert/_mesh_convert.py). The finding is a property of the IMAGE, butverify_glblisted it per MATERIAL — and the lightmap pass clones a material per instance, so a production room reported one image 46 times, under 46 near-identical names, in a single note. That is the length at which a note stops being read, which defeats the point of a note that cannot be a failure. Grouped by image with a count and the first three names; the per-material detail stays inreport["orm"]for anything that wants it. -
2026-08-29 —
verify_glbdid not tell a recipient what their reader must SUPPORT (file_utils/mesh_convert/_mesh_convert.py).extensionsRequiredis not advice: glTF 2.0 says a reader that does not implement one of the extensions named there must refuse the file. The deliverable most likely to reach a third party is precisely the one carrying a hard prerequisite — a web-delivery GLB requiresEXT_texture_webpbecause nothing core-readable survives that pass, and a pure-delivery KTX2 one requiresKHR_texture_basisu— and the report a recipient runs to find out what they are holding named neither. It now carriesextensions(required/used) and states the prerequisite as a note; a correct requirement is not a defect, sookis unaffected. The spec's own subset rule IS a failure, though: a file demanding a capability itsextensionsUsednever declares is invalid glTF and stock validators reject it outright, soverify_glbnow catches from the recipient's side the same class of fault_reconcile_texture_extensionsprevents on the producing side.test_mesh_convert+2. -
2026-08-29 — a dropped take split now says what it cost (
file_utils/mesh_convert/_mesh_convert.py).apply_glb_animationsalready warned when thefbx_takeschannel declared takes the file has no clips for, but stated only the fact ("the take split did not reach this file"), which reads as bookkeeping. Measured on a 12-shot production assembly, the consequence is the whole story: the preview of that scene carried 12 named shots and the deliverable carried one continuous clip, and this line was the reviewer's only clue they were not looking at the same thing. It now names the counts on both sides and what a consumer will experience. -
2026-08-29 —
ImgUtils.compute_atlas_layoutsquarifies, so no atlas cell is delivered pathologically thin (img_utils/_img_utils.py). A cell's two axes are two independent resolutions andbake_atlasrenders each object at its cell, so a 2.6:1 cell spends 2.6x of one axis for nothing — the cell is only ever as good as its SHORTER side. Weight-balanced bisection (this session's predecessor, itself a fix for shelf packing's 11.9:1) always cuts the longer axis, which is provably optimal for TWO items, but a lopsided two-item subtree still hands the lighter one a sliver of the full extent and a leaf keeps it. Replaced with the standard squarified treemap (Bruls/Huizing/van Wijk): rows grow along the remaining rect's SHORTER side, admitting the next item only while that does not worsen the row's worst aspect. Measured on the OFFICE_ENV production room, the same bake one algorithm apart, on the delivered atlas (46 objects, 1024 px): worst cell 101x11 px (9.18:1) -> 145x62 (2.34:1), smallest cell's short axis 11px -> 29px, median aspect 1.42 -> 1.27, atlas area used 89.1% -> 89.3%. The independent seam probe improved alongside it — the per-cell border-vs-interior excess halved (median 0.051 -> 0.029) and the signed statistic stayed a coin flip (49% of cells darker at the edge, mean -0.003), i.e. still no packing artifact, which is the thing this could most easily have broken. Every other guarantee is unchanged and re-verified by construction rather than by eye — exact tiling (area sums to 1.0, and a 200x200 sampling grid finds 0 gap cells and 0 overlap cells across equal / dominant / random / lopsided / zero-weight / single / empty inputs), area proportional to machine precision (max error 7.9e-17), and input order preserved. The last row takes the whole remaining extent and each row's last cell is derived from the row's far edge, so float drift cannot leave a seam.rows=still selects the legacy shelf packer.test_img+3, built on the room's REAL spread after the synthetic "one dominant + 30 props" fixture was measured and rejected: the two algorithms separate by only 2.09 vs 1.91 there, so a test written on it would have passed under the algorithm it exists to replace. Not claimed: the 9.2:1 worst cell measured in the room's delivered atlas is NOT this layout's doing — the layout's own worst on that spread is 2.62 — most of it isinset_atlas_rectstaking a fixed pixel gutter, which costs a small cell proportionally most (a 20px cell keeps 11px). That trade is deliberately left alone: shrinking the gutter is what the dark-border-around-every-instance bug was made of. -
2026-08-29 — shot ripples now pass keys no shot owns; boundary restore points carry a DCC tag (
core_utils/engines/shots/). Found by driving the real controller stack over a 12-shot production scene (shared objects across shots, renamed rig, zero-gap layout). (1)shot_apply's ordered phase moved keys with clamping (over=False) semantics on the reasoning that "the plan's topological order guarantees those moves never cross". It orders only the moves the plan CONTAINS — and a shared curve can carry keys no shot owns (keys sitting inside a shot that doesn't list that object). Those are never in the plan, never move, and the DCC's clamping move collapses the travelling key onto the first one it meets: on the real scene, inserting a 40-frame shot leftREPAIRED_CMPT_LOC.opacitywith two keys at frame 1567 instead of one at 1567 and one at 1592 — the shot's key landed 25 frames short, duplicated, silently. Every phase now passesover=True(blendertk's writer already ignored the flag — a directco[0]write has no neighbour clamp — so only Maya was affected). (2)ShotStore's boundary ledger entries became(state, tag): a restore point and the DCC undo step that accompanies it are two different stacks and do NOT always come in pairs — an edit that moved only BOUNDS records nothing natively. Newpush_boundary_snapshot(tag=…)/tag_boundary_snapshot/peek_boundary_tag/has_boundary_snapshotkeep whatever opaque marker the DCC layer hands them (Maya uses it to avoid firing an undo that would pop the user's previous, unrelated operation; see mayatk). The tag rides across to the redo side. 9 new tests. -
2026-08-29 —
assemble_atlasdied on a rect that rounds past the canvas, taking every other item's placement with it (img_utils/_img_utils.py). The destination slice clips at the frame while the resized source keeps its full size, so the assignment is a shape mismatch — and it raises out of the whole composite, not just that item. mayatk's own mask builder clamps for exactly this reason, so the case is real upstream. The destination is now clamped FIRST and the source cropped to match it, which is correct by construction: deriving the crop from the overhang instead lets a rect lying ENTIRELY off-canvas produce a negative slice stop, which silently wraps and mismatches again (that was the first version of this fix, caught on review). Six straddling/outside rects and a neighbour-survival case are pinned, verified to fail without it. -
2026-08-29 — a WebP delivery re-encoded orphan images into a container nothing in the file enabled (
file_utils/mesh_convert/_mesh_convert.py). The KTX2 path gates its encode on the image being SAMPLED by some texture, with a long comment on why: a container extension is a TEXTURE-level binding, so an image no texture references can never be rebound through it, and encoding anyway strandsimage/ktx2in a file whoseextensionsUsedsays nothing — glTF 2.0 core permitsimage/jpegandimage/pngonly.EXT_texture_webpis the same kind of extension and had no such gate, so a plain WebP pass re-encoded every unreferenced image it found intoimage/webpand left it unreachable. The repo's owntest_unsampled_image_never_gets_a_non_core_mime_typenamed the invariant and only ever exercised KTX2. The gate is now keyed on whether the pass hands a non-exempt image a non-core container at all (non_core_output), which is one condition for both, and the test runs over both containers. Found while deriving the extension declarations from the bindings (same day): once a declaration is only emitted for a real binding, an orphan's stranded mime stops being papered over by an unconditionalextensionsUsedentry and becomes visible for what it always was. Exempt lightmaps are unaffected — the pass that marks them binds them, so they are never orphans. Reaches the Scene Exporter in particular, which unlike the preview does not prune unreferenced textures before optimizing. -
2026-08-29 — a flat 300s conversion budget discarded a production assembly's finished deliverable (
file_utils/mesh_convert/_mesh_convert.py).DEFAULT_TIMEOUTwas a constant commented "5 minutes — enough for very large FBX files", and that claim is measurably false: a real scene FBX (250 MB, 757 meshes, 98k triangles, 30 embedded textures) converts in 180-230s with the machine idle, so the budget carried ~25% headroom and an ordinary second workload spent it — observed directly, with the Scene Exporter aborting onFBX2glTF timed out after 300sand reporting "produced no file" for an export that had already done all its work. The cost is asymmetric (too generous only delays the report of a genuinely hung process; too tight throws away a completed deliverable) and the failure is by WALL CLOCK rather than by content, so it passes on a quiet machine and fails mid-workday, which is how it went unnoticed. The default now derives from the input:conversion_timeout(src)returnsmax(DEFAULT_TIMEOUT, size_mb * TIMEOUT_SECONDS_PER_MB)at 3 s/MB — the measured idle rate with room for a busy workstation — keeping the old constant as a FLOOR so nothing small converts on a shorter leash than before, and falling back to that floor when the size cannot be read (a budget must never be why a conversion is not attempted).timeout=gains a negativeAUTO_TIMEOUTsentinel as its default: an explicit number is still used as given, andNonestill means no limit (which is alreadysubprocess.run's meaning for it, and why None could not be the "derive" signal). Reaches both writers of the deliverable at once, the preview and the Scene Exporter, which share this call. -
2026-08-29 — a WebP→KTX2 rerun shipped INVALID glTF, the texture pass denied resampling it had done, and a selection preview called most of the scene unlit (
file_utils/mesh_convert/_mesh_convert.py,net_utils/preview_server.py,net_utils/preview_viewer.html). Three faults in the delivery path, all found running the real pipeline over a production assembly. (1) A WebP delivery REQUIRESEXT_texture_webp(nothing core-readable survives it); a later KTX2 pass re-encodes past those bindings and removed the declaration fromextensionsUsedonly — leaving it inextensionsRequired, which glTF 2.0 defines as a subset ofextensionsUsed. The file then demanded a capability it no longer declared, and every stock validator rejects it. The repo's own rerun test existed and passed, because it asserted theextensionsUsedhalf and never looked atextensionsRequired. Declarations are now DERIVED from the final bindings by_reconcile_texture_extensionsrather than accumulated by whichever branch ran: used when some texture binds the extension, required only when some binding of it has no core-readablesourceto degrade to; an array emptied of this class's extensions is dropped rather than shipped as[](both carryminItems: 1). Idempotent, and self-healing on a file that arrives with stale declarations. (2)describe_texture_passreadmax_size=0as "pixels untouched", but that flag means "never CLAMP" — KTX2 snaps every non-exempt image down to a power of two regardless, becauseKHR_texture_basisuneeds multiple-of-4 edges and a full mip pyramid. So the one sentence written to stop the pass misreporting itself misreported a silently halved map, in the delivery mode where that matters most. (3) The bake manifest is a SCENE record that every export carries whole, and_pass_lightmapscounted all of it against a selection-scoped GLB: pushing 12 objects of a 48-object bake reported 36 of them previewing UNLIT, crying wolf precisely when the preview was correct — andapply_glb_lightmapswarned once per absent object on the way, turning a correct push into dozens of scary lines. Newlightmap_manifest_coveragesplits a manifest intopresent/ambiguous/absentthrough_resolve_lightmap_node, the match rule (exact, then namespace-tolerant leaf, never a guess on a duplicate) extracted so the binder and the report cannot disagree about what "missing" means — which is what caused this. An object with no node is out of scope, counted inresult["lightmaps"]["out_of_scope"]and summarised once; ambiguous stays a loud failure, being in the file and genuinely unbindable.lightmap_summarynames that count too when there is one — "3/3 bound" over a 50-object bake reads as suspiciously few until the other 47 are accounted for, and it is scope rather than a miss, since an object not in the push cannot be unlit in it. Measured on the production push afterwards:47/47 bound, out_of_scope 1.test_mesh_convert+4,test_preview_server+2. -
2026-08-29 — an animated deliverable opened on a clip that plays nothing (
file_utils/mesh_convert/_mesh_convert.py,net_utils/preview_viewer.html).default_cliptook the first DECLARED clip, on the reasoning that a file which split shots means the shots. But Maya's split emits an AnimStack per declared range and bakes no curve for a range in which nothing moves — a hold, or a shot whose motion belongs to objects outside the export — so those clips arrive named, listed, and carrying zero channels. Measured on a 12-shot production assembly:Shot_1,Shot_6andShot_11came through empty,Shot_1was the default, and the preview opened on 0.00s of nothing with eleven populated clips behind it. Read as broken animation; was a shot that holds.apply_glb_animationsnow marks such a clip"empty": true(a normal authoring outcome, and invisible from the clip list, which is exactly why it misread) anddefault_clipprefers the first declared clip that can actually play — falling back to any non-empty clip, then to the first, so an all-empty file still names a default rather than none. Empty declared shots are reported once at INFO. The viewer labels them too (Shot_1 (7–100, empty)): picking one still plays nothing, and the label is the difference between a broken preview and an empty shot.test_mesh_convert+2. -
2026-08-29 — the preview page put a 404 in every session's console (
net_utils/preview_viewer.html). The serve root holds the deliverable and nothing else, so the browser's automatic/favicon.icorequest always failed, and a red "Failed to load resource" is a false lead for anyone who opened devtools because something genuinely IS wrong. An inlinedata:SVG icon in the head; the browser never issues the request. Verified against the headless-browser harness: the console is now clean on a real production push. -
2026-08-29 — lightmap image primitives: atlas cells were up to 12:1 slivers, and the gutter dilation allocated tens of GB (
img_utils/_img_utils.py).compute_atlas_layoutpacked items intoround(sqrt(n))weight-balanced SHELVES, whose height is the shelf's weight share -- so one dominant object drove every other shelf thin, and a thin shelf's cells are thin at any width. A room of weight 200 among 30 props of 0.5 gave each prop a 341x29 px cell in a 2048 atlas (11.9:1; the vertical lighting signal is gone, andinset_atlas_rectsthen ate half of the 29). The default is now weight-balanced bisection -- split the set into two near-equal-weight halves, cut the rect along its LONGER axis at that ratio, recurse -- which puts the same props at ~143x68 / 71x137 (worst aspect 11.9:1 -> 2.1:1) for identical area. Exact tiling, area proportionality and input order are unchanged and still pinned;rows=opts back into the shelf packer.dilate_imagebuilt a fresh zeroed full-image buffer per neighbour offset (16 whole-image allocations per pass -- ~40 GB of alloc/zero traffic for one 4096 atlas at an 18px gutter); it now accumulates through precomputed slice pairs into two reused buffers, and rounds instead of truncating on integer dtypes (gutters were biased dark by up to an LSB). Also:encode_hdr_for_websanitizes non-finite texels (enoughinfmade the percentile itselfinfand the whole map encoded BLACK;NaNcast to uint8 as undefined garbage) and encodes PNG at max compression;_save_via_cv2writes EXR as half float and RAISES on the silentimwriteFalse;assemble_atlasclamps a rect that rounds past the canvas instead of dying on a shape mismatch;fill_empty_texelsgathers only the empty texels' labels. -
2026-08-29 — boundary-snapshot ledger on
ShotStore(core_utils/engines/shots/shot_model.py). Scene keyframes ride the DCC's native undo queue; shot BOUNDS live in the store, outside it — and the snapshot stack that paired the two was a per-CONTROLLER list in the mayatk sequencer panel, while the Shots settings panel pushed nothing, so a settings-panel reorder followed by Ctrl+Z popped an unrelated older snapshot. Newpush/discard/restore/redo_boundary_snapshot+snapshot_bounds/clear_boundary_snapshots: one ledger per store (= per scene, so a scene swap isolates it for free), shared by every panel that mutates boundaries. Records carry each shot's FULL identity (name, description, locked, metadata, objects), andrestorereconciles membership symmetrically: a shot absent from the snapshot is removed (undoing an insert no longer leaves a phantom overlapping the restored layout) and a shot the store lost is re-created from its record (undoing a delete, redoing an insert — the keys were never deleted with the store entry, so reconstruction loses nothing); the new redo direction re-applies what an undo stepped back from, so a DCC redo no longer re-applies the scene keys while the bounds stay restored (the keys-outside-their-shot state). A new push clears the redo branch, mirroring every undo queue; capped at 50; session-only, never persisted. 9 tests intest_shots_core.py; consumed by mayatk and blendertk controllers + both Shots settings panels. -
2026-08-28 — an animated GLB could not say which of its clips are shots, and the WebXR preview played none of them (
file_utils/mesh_convert/_mesh_convert.py,net_utils/preview_viewer.html,core_utils/engines/shots/shot_model.py). Probe-measured on Maya 2025 → FBX2glTF 0.13.1 with a two-shot scene: the takes DO reach the GLB — one glTF animation per declared shot, named by clip — but three things about them are unsayable from the file. Maya's exporter keeps its whole-timelineTake 001AnimStack alongside the takes it split out and it converts FIRST, soclipAction(animations[0])plays the entire timeline rather than shot 1. Every clip's own sampler times are rebased to zero, soSHOT_B(frames 20–30) andSHOT_A(1–10) both start at t=0 and the sequence is lost. And the shot definitions ride as JSON strings nested underextras.fromFBX.userProperties, so a consumer parses the JSON it just parsed. Newapply_glb_animationswritesextras.animation_web, the animation twin oflightmap_weband the same kind of applier: self-feeding from the file's ownfbx_takes/shot_metadatachannels (no argument a caller could forget), run unconditionally after every conversion, a clean no-op on a static GLB. It names every clip by its animation INDEX, marks the ones a shot declared, carries each declared clip's authoring frame range plusoffset(its first frame in seconds) and the scenefps, and names thedefault_clipa player should open on. The glTF's ownanimationsorder is left exactly as written — renumbering the file to fix an index convention would break every consumer that keys on it, and the block makes the order answerable instead. A declared take with no matching clip is warned (metadata describing clips the file lacks means the split never ran). The channel read is nowdata_export_channel(gltf, key), generalized out of_lightmap_manifest— the puzzle is the two on-disk carrier shapes, not the channel, and the second hand-rolled copy is the one that probes only the nested shape and goes silently inert on a natively exported GLB.ShotStore.to_export_viewnow publishesfpson theshot_metadataenvelope (additive, schema v1 stands): the frame numbers it ships were unitless to every consumer that did not author the scene. The preview viewer mounts anAnimationMixer— it had none, so an animated deliverable previewed as a still with nothing on screen saying why — with a clip picker that leads with the declared shots and labels each with its frame range, play/pause, a scrubber, space /[/], and autoplay (a DOM overlay is not rendered in an immersive session, so a headset user has no transport to press).verify_glbreports the clip counts to a recipient, and notes animation that nothing describes;HANDOFF_INSTRUCTIONSstates the block, so a standalone reader is told in the artifact rather than in a doc it was never given.test_mesh_convert+11,test_preview_server+2. -
2026-08-27 — a manifest row could not say “this object does not animate”, so static props listed beside a fading one were keyed with its fades (
core_utils/engines/shots/manifest/manifest_model.py).parse_csvresolved a continuation row's behaviors asrow_behaviors or detect_behaviors(current_step.description). Theorfires whenever the row's OWN description detects nothing — so a row describing itself as static (static support bracket does not move) inherited the step's fades regardless, and Build keyedfade_in/fade_outonto set pieces that never move. The code's own comment already stated the intent — “Own description overrides, otherwise inherit from parent step” — but gating on the detected list's truthiness rather than the description's presence made “no behaviors” unsayable, which is precisely how a prop beside a fading one has to be expressed. A second fault compounded it: the inherit source wascurrent_step.description, which continuation rows are merged INTO before the next row is read — so a row without a description inherited from whichever sibling preceded it, making one step's result depend on row order. Measured: a step describedStatic setup with no motioncarryingobj_b(the panel fades in) then a blankobj_cgaveobj_c['fade_in'], sourced entirely from obj_b's text and from a step that itself detected nothing. Both now resolve againststep_behaviors, the list already computed at the step row: order-independent, faithful to “inherit from parent step”, and one regex pass cheaper per blank row. The useful case — a blank row inheriting the step's fades — is unchanged, which is what the C-130H-layout fixtures pin.test_shots_manifest_core.py+3, two of which fail against the previous logic; pythontk 3637 green, and mayatk's sequencer/manifest suites measured identical to a pre-fix baseline.