-
2026-09-19 -- a sectioned record crosses in its section's shape, a merge snaps by the receiving scene, a bad emissive slot no longer sinks the record, and the KTX2 availability question never raises (
core_utils/scene_records.py,core_utils/engines/shots/shot_transfer.py,core_utils/engines/textures/region_masks.py,img_utils/_img_utils.py). From a review of the pending change set, each a code-path trace. Section shape:RecordTransfer.sections/receiveshipped and landed theshotssection as the BARE store whenever no DCC owner supplied the hooks -- while the section's wire shape isShotTransfer.encode's envelope ({"version", "store", ...}, what every consumer decodes), so a consumer read an empty store and a receive wrote the envelope AS the record. The shape belongs to the codec:ShotTransfer.section_out(state, ctx)/section_in(section, ctx)(encode in the carrier's spelling scoped to what ships; decode throughctx.renameand the importer's adapters), and the engine asks a sectioned record's codec for them when no owner takes over (_section_hook). The pinned test had asserted the bare shape; it now asserts the envelope, and a round trip lands the store. Snap policy:ShotTransfer.mergeput the incoming bounds on this scene's clock with the INCOMING store'ssnap_whole_frames-- a module saved without snapping landed 240.8..264.0 in a scene that guarantees whole frames;_on_clock(..., snap=)now takes the receiving store's, the one whose settings the merge keeps. Emissive slots:RegionGroupRegistry.merge_recordsorted the arriving groups by slot before its own per-group guard ran, so onenullor non-numeric slot raisedTypeErrorinside the sort and the whole record was noted "not merged"; the ordering coerces (MAX_SLOTSlast) and the guard re-slots it as designed. Predicate:ImgUtils.ktx2_available()propagated the resolver'sFileNotFoundError-- masked until now by the callers' own try blocks; a predicate answers False. Also:SceneRecords.resolve_class(module, name)is the one lazy(module, class)resolvercodec()andSceneStoreBase.owners()each carried;ShotTransfer.decodeno longer retimes a ledger it rebuilds from the section anyway (_on_clock(ledger=False));SceneStoreBase.merge_carriers/discard_carrierstakeadapters=(DCC facts an owner's hook reads throughTransferContext.adapter, e.g. which datablocks the other scene brought), andImgUtils.settle_ktx2_encoder(prompt, refused, installed=None) -> boolis the offer-or-refuse step every KTX2 panel (both Scene Exporters, the WebXR preview, the Map Converter) was writing for itself with its own drift. Tests:test_scene_records(TestRecordHandoff,TestRecordTransferemissive slots),test_shot_transfer(TestMerge),test_img(ktx2_available). -
2026-09-19 -- the web export runs KTX2 RDO on a 1024 dictionary, and a map tool can choose its own (
file_utils/mesh_convert/_mesh_convert.py,img_utils/ktx2_encoder.py,img_utils/_img_utils.py,core_utils/engines/textures/map_optimizer.py). The dictionary is where RDO's time goes. Measured throughoptimize_glb_textureson a production set's own maps (8 normal maps at 4K, 8 ORM packs at 2K, lambda 1): toktx's own 4096 took 245 s, 1024 took 129 s for +2.2% image bytes (normals +1.8%, ORM +3.5%), and 256 bought only 20 s more for +4.6%. The maintainer's call is that those bytes do not justify the time on an export, soMeshConvert.WEB_DELIVERY_UASTC_RDO_DICTIONARYis 1024 -- theweb_delivery_texture_params/optimize_glb_texturesdefault, named bydescribe_texture_pass. A standalone map tool exposes the choice instead:MapOptimizer.optimize_map/assesstakeuastc_rdoanduastc_rdo_dictionarythroughImgUtils.save_image, and the newMapOptimizer.resolve_uastc_rdogives them to the UASTC maps only, a normal map capped at toktx's 0.75 (the cap isKtx2Encoder.UASTC_RDO_NORMAL_MAX/rdo_fornow;MeshConvert.UASTC_RDO_NORMAL_MAXreads it and is READ-ONLY -- aClassProperty, so an assignment to it no longer changes the policy; set the encoder's), and reports a request against another container rather than dropping it. Fixed on the way:Ktx2Encoder.encodeaccepted a per-call dictionary and dropped it before building the argv (onlyargs_forwas tested); the size range is toktx's documented 64-65536 (RDO_DICTIONARY_RANGE; toktx itself enforces none -- 63 and 65537 exit 0), not a stricter 256; a bad RDO value is refused before any work -- per image it read as one failed encode each, every one shipping its PNG -- and beforeoptimize_maparchives the source;Ktx2Encoder.rdo_kwargspasses the RDO keywords only when used, so a registered encoder need not model them. Tests:test_ktx2_encoder,test_mesh_convert(TestWebDeliveryDials,TestOptimizeGlbKtx2),test_map_optimizer(TestKtx2Compression),test_img(ImgKtx2RoutingTest). -
2026-09-19 --
ShotTransfer.mergerenames a shot only to resolve a clash, and puts the incoming times on the scene's clock (core_utils/engines/shots/shot_transfer.py). A legacy name the rule refuses ("Shot 2.1", saved before names were validated) was respelled (Shot_2_1) whenever the receiving scene had shots of its own -- a scene without any adopted it as saved -- and the note gave a reason that was not true ("this scene already has that name"). A name is now numbered throughShotStore.unique_amongonly when its clip spelling clashes, ignoring case; any other arrives as saved, as the store keeps a legacy name (marked, never repaired). The incoming shots, markers and ledger claims were appended in their own scene's frames, so a 30 fps module merged into a 24 fps scene with shots put every shot at 1.25x its time while the DCC had landed the keys at the same seconds; they are rescaled onto the scene's clock now (_on_clock, the one retimedecodealso runs), andmerge_recordsays so. A ledger in its legacy record shape (a bare time, which the ledger itself loads as an unowned sample) raisedTypeErrorin the merge -- a crossing notes the record "not merged", so the whole store was lost; both ledgers are canonical first now. Measured on a production assembly (18 legacy-named shots at 30 fps) imported into 30 and 24 fps scenes with and without shots of their own: names, ranges and members arrive as saved, or rescaled exactly as the keys were (test_shot_transfer). -
2026-09-19 --
FbxMedia.drop_apparatus: a baked rig's apparatus leaves the written FBX, andExportRun.drop_rig_apparatusis the row that asks for it (file_utils/mesh_convert/fbx_media.py,core_utils/export_profile.py). The consumer half ofRigMachinery, run on a binary FBX instead of a scene: the producer's census ({path: kind}) is matched against the Models by leaf name, andRigMachinery.selectdecides with the file's own facts as the net -- a named node whose subtree holds content (any Model class butNull/LimbNode/Line/NurbsCurve), an influence of a skin that stays, a scene-record or curve-proxy carrier, or a node wired to an object this writer does not own is refused, by name. Each Model that goes takes its subtree, its attribute, its curve geometry with that geometry's deformers, and the animation only it drove; the connections, bind-pose entries andDefinitionscounts follow, and everything else is copied byte for byte. Measured on a production assembly's FBX with a stand-in section: 707 nodes removed in 0.4 s, FBX2glTF 405 -> 264 s, the GLB 2487 -> 1780 nodes with its meshes, skins, clips and accessors unchanged.ExportRun.drop_rig_apparatusis a mode likeverify_deliverables-- popped, off for a caller that never names it, and inert (with a note) on a USD run, which writes no FBX (test_fbx_media,test_export_profile). -
2026-09-19 --
ExportRun.clip_mode/ExportRun.splits_takes: the Animation Clips row resolved once, and whether the write splits takes (core_utils/export_profile.py). The resolver both DCC exporters carried a private copy of (_animation_clips_mode: a pre-combo preset'sTrueisboth, anything falsyfull, an unknown mode refused) isExportRun.clip_mode, andwith_tasksderivessplits_takes-- the row runs in a shot-bearing mode -- so a task that must know whether the FBX write will split takes reads it off the run, not off a task dict it never sees. mayatk's smart-bake task is the first reader (test_export_profile). -
2026-09-19 -- scene records cross into another scene by declaration:
Merge,TransferContext,RecordTransferandSceneStoreBase's crossing surface (core_utils/scene_records.py,core_utils/engines/shots/shot_transfer.py,core_utils/engines/key_stash/key_stash_model.py,core_utils/engines/textures/region_masks.py,core_utils/handoff_manifest.py). A record now declares what it becomes when another scene's copy arrives beside the scene's own --RecordSpec.merge(DERIVE: a deliverable, published afresh from the merged scene;OWN;UNIONby key ormerge_key, the scene's own entry winning;CODEC: a domain merge) andportable(it rides a DCC hand-off) -- and one engine,RecordTransfer, applies it on every route: another scene's carriers merging into this one's (between, thenis_empty/summary-- the one question worth asking a user, about the records that hold something: a key stash with no clip is no entry -- andapply), and a hand-off sidecar (sections/receive; the shot store keeps itsshotssection, every other portable record rides the newHandoffManifest.RECORDS). The codecs, registered inSceneRecords.CODECS:ShotTransfer.merge_record(incoming shots renumbered, a clashing name numbered AND noted, a rescale onto this scene's clock noted, the renumbering published asctx.remaps["shot_id"]),KeyStash.merge_record(fresh clip ids, times rescaled to this scene's rate, clips following their source shots),RegionGroupRegistry.merge_record(a group whose slot is taken is re-slotted, and the note says an engine binding needs re-wiring);ShotTransfer.respell_record/KeyStash.respell_recordrespell only the names they hold, so a shot named like a node the crossing renamed keeps its name.SceneStoreBasegains the DCC-agnostic orchestration --OWNERS(record key -> the DCC class keeping state beside it, with optionaltransfer_out/transfer_in/merge_carrier/discard_carrierhooks),transfer_sections/receive_sections,merge_plan/merge_carriers/discard_carriersover five carrier hooks -- so a DCC store supplies its carriers and nothing else. A settle removes the other carriers BEFORE it produces the deliverables again, so a discard derives them from what the scene keeps (a carrier the import adopted still held the dropped records), andflush_owners()asks each owner to store what it holds unwritten (theflush_pendinghook:ShotStore.flush_pending,KeyStash.flush_pending) -- the hand-off routes call it, and an importer does before the other scene's nodes land. A DCC store writes on idle, so a script that edited and then imported lost the edit, and in a scene with no carrier of its own the late write landed on the module's records the import had adopted; flushed inside the settle it would land in that carrier too, so the settle does not. A record whose counted list is empty but which holds something else (a shot store with markers alone) is named in the summary without a count. Nothing crosses silently: every route returns aTransferContextwhose notes are the report. Pinned bytest_scene_records(TestRecordTransfer,TestRecordHandoff,TestStoreCrossings). -
2026-09-19 --
HandoffBridge.param_aliases: a bridge renames a parameter without breaking its callers (core_utils/app_handoff.py). ADeprecation.valuesresolver on the class (so the retirement is on the roster from import);merge_paramsmoves a value given under the retired key to the live one BEFORE the defaults merge -- the live key's default would otherwise shadow it -- and warns, and the live spelling wins when both are given. mayatk's and blendertk's bridges retireINCLUDE_SHOTSforINCLUDE_SCENE_DATAthrough it (test_bridge). -
2026-09-19 --
ShotEditLedger.remapmoves every claim once (core_utils/engines/shots/shot_ledger.py). The pairs were applied one after another, so a claim moved onto the source frame of a LATER pair was moved again whenever that later key was not claimed itself -- and every mover (mayatk's and blendertk's ripple writers, key movers and retimes) handsremapthe pairs of ALL the keys it moved. Any ripple whose delta equalled a key spacing did it: measured in Maya, a +10 respace over a start pin at 60 and an animator key at 70 left the pin at 70 and its claim at 80, on the animator's key, where the next reconcile could cut or disown the wrong key while the real pin went unclaimed. Every pair is now matched against the claims as they stood before the call and all the moves land together; no API change (test_shots_coreTestEditLedgerRemap). -
2026-09-19 --
ShotStore.snap(frame, direction="nearest"): a bound that must enclose keys snaps outward (core_utils/engines/shots/shot_model.py)."down"floors and"up"ceils to a whole frame (a frame within 1e-6 of a whole one stays put); the default is unchanged, and any other value raisesValueError. A bound derived from content -- a trim's start and end, a shrink's content clamp, a drag's grow past the edge, Move to Shot's room -- rounded to the NEAREST frame could land up to half a frame inside that content, and a retime leaves keys on fractional frames; mayatk's and blendertk's sequencers now pass the direction at those sites (test_shots_coreTestSnapEnclosesContent).