github m3trik/pythontk v0.11.1
pythontk v0.11.1

3 hours ago
  • 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 / receive shipped and landed the shots section as the BARE store whenever no DCC owner supplied the hooks -- while the section's wire shape is ShotTransfer.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 through ctx.rename and 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.merge put the incoming bounds on this scene's clock with the INCOMING store's snap_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_record sorted the arriving groups by slot before its own per-group guard ran, so one null or non-numeric slot raised TypeError inside the sort and the whole record was noted "not merged"; the ordering coerces (MAX_SLOTS last) and the guard re-slots it as designed. Predicate: ImgUtils.ktx2_available() propagated the resolver's FileNotFoundError -- 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) resolver codec() and SceneStoreBase.owners() each carried; ShotTransfer.decode no longer retimes a ledger it rebuilds from the section anyway (_on_clock(ledger=False)); SceneStoreBase.merge_carriers / discard_carriers take adapters= (DCC facts an owner's hook reads through TransferContext.adapter, e.g. which datablocks the other scene brought), and ImgUtils.settle_ktx2_encoder(prompt, refused, installed=None) -> bool is 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, TestRecordTransfer emissive 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 through optimize_glb_textures on 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, so MeshConvert.WEB_DELIVERY_UASTC_RDO_DICTIONARY is 1024 -- the web_delivery_texture_params / optimize_glb_textures default, named by describe_texture_pass. A standalone map tool exposes the choice instead: MapOptimizer.optimize_map / assess take uastc_rdo and uastc_rdo_dictionary through ImgUtils.save_image, and the new MapOptimizer.resolve_uastc_rdo gives them to the UASTC maps only, a normal map capped at toktx's 0.75 (the cap is Ktx2Encoder.UASTC_RDO_NORMAL_MAX / rdo_for now; MeshConvert.UASTC_RDO_NORMAL_MAX reads it and is READ-ONLY -- a ClassProperty, 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.encode accepted a per-call dictionary and dropped it before building the argv (only args_for was 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 before optimize_map archives the source; Ktx2Encoder.rdo_kwargs passes 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.merge renames 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 through ShotStore.unique_among only 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 retime decode also runs), and merge_record says so. A ledger in its legacy record shape (a bare time, which the ledger itself loads as an unowned sample) raised TypeError in 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, and ExportRun.drop_rig_apparatus is the row that asks for it (file_utils/mesh_convert/fbx_media.py, core_utils/export_profile.py). The consumer half of RigMachinery, run on a binary FBX instead of a scene: the producer's census ({path: kind}) is matched against the Models by leaf name, and RigMachinery.select decides with the file's own facts as the net -- a named node whose subtree holds content (any Model class but Null/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 and Definitions counts 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_apparatus is a mode like verify_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's True is both, anything falsy full, an unknown mode refused) is ExportRun.clip_mode, and with_tasks derives splits_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, RecordTransfer and SceneStoreBase'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; UNION by key or merge_key, the scene's own entry winning; CODEC: a domain merge) and portable (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, then is_empty / summary -- the one question worth asking a user, about the records that hold something: a key stash with no clip is no entry -- and apply), and a hand-off sidecar (sections / receive; the shot store keeps its shots section, every other portable record rides the new HandoffManifest.RECORDS). The codecs, registered in SceneRecords.CODECS: ShotTransfer.merge_record (incoming shots renumbered, a clashing name numbered AND noted, a rescale onto this scene's clock noted, the renumbering published as ctx.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_record respell only the names they hold, so a shot named like a node the crossing renamed keeps its name. SceneStoreBase gains the DCC-agnostic orchestration -- OWNERS (record key -> the DCC class keeping state beside it, with optional transfer_out / transfer_in / merge_carrier / discard_carrier hooks), transfer_sections / receive_sections, merge_plan / merge_carriers / discard_carriers over 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), and flush_owners() asks each owner to store what it holds unwritten (the flush_pending hook: 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 a TransferContext whose notes are the report. Pinned by test_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). A Deprecation.values resolver on the class (so the retirement is on the roster from import); merge_params moves 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 retire INCLUDE_SHOTS for INCLUDE_SCENE_DATA through it (test_bridge).

  • 2026-09-19 -- ShotEditLedger.remap moves 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) hands remap the 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_core TestEditLedgerRemap).

  • 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 raises ValueError. 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_core TestSnapEnclosesContent).

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.