github m3trik/pythontk v0.10.0
pythontk v0.10.0

4 hours ago
  • 2026-09-15 -- client program identifiers are out of pythontk's tracked source (file_utils/mesh_convert/_mesh_convert.py, export_verify.py, str_utils/_str_utils.py, net_utils/preview/viewer.html, and test data in eight test modules). Measurement notes and synthetic test data named a client's programs and objects; they now carry neutral names, with equal-length stand-ins wherever a test measures string lengths, and older entries in this file use the same stand-ins. m3trik's release gate (scripts/check_public_hygiene.py) keeps identifiers out: push.ps1 runs it over everything a release would publish, reading a denylist kept outside every public repo, since a pattern list in public source would publish the names it guards. The built-in speedrun mapping template keeps its name: the word is generic, and renaming a shipped template would break the users who select it.

  • 2026-09-15 -- KeyStash owns the undo step and the re-read both DCC adapters need (core_utils/engines/key_stash/key_stash_model.py). New _undo_step(name) batches the store's mutations and writes the record inside the adapter's undo chunk on the way out, through the _undo_chunk and _save_in_step hooks. active() re-reads the record in place when the backend reports record_changed(), an optional addition to the persistence protocol, and is_previewing moves here from both adapters, where it was identical. No flush may run mid-step: mayapy's evalDeferred and background Blender both flush at once, and that write landed outside the step, where no undo could take it back (measured in both adapters). The in-step write also runs when the operation raises, since the batch's own exit flush would write through save(), outside the Maya adapter's undo queue. test_key_stash_core +3, each failing first.

  • 2026-09-15 -- the deliverable gates read a declared Full Sequence Only mode, and the export check table schedules the new UV snapshot report (file_utils/mesh_convert/export_verify.py, _mesh_convert.py, core_utils/export_profile.py). ExportVerifier.check_fbx_takes failed a correct Full Sequence Only FBX "declared but absent" on every shot, and check_clips_vs_takes did the same to its GLB: fbx_takes lists the scene's shots in every mode. Both now read the mode the exporter declares on the shot_metadata envelope (new MeshConvert.SHOT_CLIP_MODE_KEY, beside fps): fbx_takes SKIPs with the reason, and the clips gate still judges the whole-timeline clip against the shots' range but stops calling them missing. A file that does not declare the mode, or declares a shot-bearing one, still fails -- one stack alone is also what a split that silently failed leaves. ExportProfile.CHECK_DEPENDENCIES gains check_uv_snapshots (mayatk's report of leftover auto-unwrap UV snapshots, which no task changes). test_export_verify +2, the Full Sequence one failing first.

  • 2026-09-15 -- every lightmap copy inside a GLB says what the GLB ships, whoever produced it, and a task can record an edit it keeps (file_utils/mesh_convert/_mesh_convert.py, core_utils/task_factory.py). apply_glb_lightmaps corrected the per-node lightmapInfo markers but left the data_export lightmap_metadata manifest naming the source .exr at the pre-normalisation intensity (measured on the TestGlbLightmaps fixture: room_Lightmap.exr @ 1.0 beside lightmap_web's room_Lightmap.png @ 0.5, only its locate hints gone): the correction was keyed by node name, and no node is named after an entry of the carrier's manifest. Entries are now matched to their nodes by the binder's own rule (_resolve_lightmap_node, namespace-tolerant) and corrected only when every node an entry resolves to ships one lightmap; an unbound entry, and a manifest newer than the reader, are left as found. New MeshConvert.fix_glb_lightmap_metadata(glb) is the same repair for a GLB the applier never touched: blendertk's native glTF export shipped floor_mat_Lightmap.exr @ 1.0 with the absolute authoring folder as dir, beside floor_mat_Lightmap.png @ 3.191705 (Blender 5.1). It feeds itself: each node's values are read off the file (_lightmap_final_values -- the materials its primitives wear, looked up in lightmap_web), which is also what apply_glb_lightmaps now hands the reconcile in place of a second bookkeeping dict. A node wearing two different lightmaps is not guessed at, and a file with no lightmap_web is left as found. lightmap_web is read where the viewer reads it (_lightmap_web_manifest: the first scene's extras, as a native export writes it, then the root's), so verify_glb's lightmap flag, set_glb_normal_scale(lightmapped_only=True) and optimize_glb_textures' lightmap exemption stop reading a natively exported deliverable as unlit (the texture pass re-encoded such a lightmap lossily wherever it was sampled off texCoord 1). TaskFactory.record_kept_edit(label) and kept_edits are the other half of stage_deferred_restore: a task names an edit no restore unwinds where it makes it, so a run that stops before its write can name exactly what it left, and run_deferred_restores closes the run and clears the record. test_mesh_convert +7 and test_task_factory +1, each failing first; five mutations of the fix (no node values, no ambiguity guard, a root-only probe, an untouched manifest, a record never cleared) each fail the tests written for them. Full suite: 4714 tests, 0 failures, 14 skipped.

  • 2026-09-14 -- a UDIM set is ONE material to the shader builders, and a named asset converts every tile (core_utils/engines/textures/map_factory/_map_factory.py). MapFactory.group_textures_by_set keeps a set per tile on purpose -- the factory converts each tile's images on their own -- and both DCCs' GameShader.create_network built a shader per set, so a 10-tile material became 10 shaders. MapFactory.collapse_tile_sets (new) is the shader builder's view over those sets: one set per material, each map once, as its lowest tile. It keeps a real tile rather than a <UDIM> pattern because the hosts tile from one -- Maya's uvTilingMode and Blender's TILED source both find every sibling tile from the file they are given, while Blender loads a literal <UDIM> path as a one-tile image (measured, 5.1). A NAMED asset (prepare_maps(group_by_set=False)) had a silent drop of its own: its single inventory holds one path per map type, so a merged 2-tile set converted tile 1001 alone and never wrote 1002 (measured: the call wrote rock_Base_Color.1001.png only). Its tiles now convert a tile at a time and come back as one list. MapFactory.get_tile_paths (new) lists a set's tiles on disk, for a host deciding whether to tile at all: a lone tile-numbered file is not a set, and tiled, wall_BaseColor.1024.png leaves 0-1 UVs for tile 1024 and renders black, where read as one image it renders wherever the UVs sit -- so both DCCs tile only a set of two or more. test_map_factory_grouping +4 (a material's tiles collapse into one set; the lowest tile and the material's untiled maps are kept; a named asset converts every tile; a set's tiles on disk for each scheme and for its pattern path, never another container, a resolution tag or another scheme), all red first; the full suite green, 4706 tests, 0 failed.

  • 2026-09-14 -- a grayscale roughness or metallic map reaches the GLB with its values: FBX2glTF converts a copy whose grayscale embeds carry every colour channel (file_utils/mesh_convert/fbx_media.py, _mesh_convert.py). FBX2glTF 0.13.1 packs a material's occlusion-roughness-metallic texture from each map's red, green and blue and reads a channel the decoded image lacks as white, so a grayscale map shipped as roughness 1 and metallic 1 -- and metallic 1 renders black under a lightmap. Measured on Maya-built twins and on a synthetic Stingray PBS quad (roughness a 60..220 ramp, metallic 8): stored RGB the packed texture carries G 60-220 / B 8; stored L, LA, 16-bit or as a grayscale JPEG it carries 255 in both, per map (a grayscale roughness beside an RGB metallic whitens green alone), while a palette map packs correctly. It is the common case -- GameShader splits a packed ORM into L maps -- and until now only the scene sidecar's diff-based metallic_roughness repair covered it, so a material or producer that section skipped shipped metallic 1. FbxMedia.expand_grayscale (new) re-encodes every grayscale PNG/JPEG embed as RGB (RGBA with alpha; 16-bit rescaled over its full range, where Pillow's own conversion clips it to white), and MeshConvert.fbx_to_glb hands the converter that copy: nothing is written without a grayscale embed, and a failed expansion logs and converts the file as before. The copy lives in a store of the call's own, removed on every exit (an interrupted expansion included), and a converter failure names the source FBX rather than the copy it read. Fixed at the converter's input rather than Maya-side (the other option on file), so the blast radius is exactly the broken input and an FBX from any producer is repaired; re-measured, the Maya-built L, mixed and ORM-split twins now pack like their RGB twin through fbx_to_glb, while the bare binary still packs them white. FbxMedia.downsize now shares the decode/encode/write loop (_rewrite_images). Cost: one full payload read per binary-FBX conversion (2.2 s on a synthetic 190 MB, 820k-record file). Left alone, on measurement: suspect_orm_materials still tests blue only -- with no roughness map FBX2glTF bakes the roughness scalar into green, so green 255 is an ordinary fully rough material, not a fault. test_fbx_media +2 (each grayscale mode expands carrying its value in every channel, with colour, palette and non-PNG/JPEG embeds copied byte for byte; a file with none is not written) and test_mesh_convert +3 (a grayscale map packs like its RGB twin through the real converter; the converter alone still packs it white; the conversion reads the expanded copy and survives a failed expansion) -- red first except the converter-alone test, which pins the premise so an upgrade that fixes it says the pass can go. The two converter tests run an FBX2glTF that is already installed and skip otherwise, on build_stingray_fbx: a synthetic FBX, since the SDK reads nothing without a FileId/CreationTime header and binds no texture without the Texture/Video records' own properties.

  • 2026-09-14 -- the GLB presence gate reads presence from the authored channels, so an opacity nothing mirrored is gated, and a one-frame cut resolves to its later key (file_utils/mesh_convert/_mesh_convert.py). apply_glb_visibility gated only tracks carrying a visibility key, so a hand-keyed opacity shipped ungated unless some producer had written a mirror first -- which inside one export bracket came down to producer ORDER (the WebXR preview's track walk runs before the render-effects preparer repaired a missing mirror), and is why effect_preview_channels synthesized a mirror of its own. MeshConvert._presence_keys is now the one definition of presence -- a real ramp gates on the ramp (as before), else the authored visibility, else the opacity keys read as > 0, else nothing (a highlight alone) -- and apply_glb_visibility selects and ranges its tracks by it; the synthesized mirror is gone. Found beside it: _visibility_runs sorted keys by (frame, value), so two keys on one frame always resolved to the larger value and every one-frame cut to hidden read as visible; keys sharing a frame now keep their authored order, the tie rule _strictly_increasing already applies. A fading ramp that cuts to zero on one frame had already met it: its presence runs put both states on that frame, so the node stayed present, at zero alpha, until the next key. test_mesh_convert +6 (a mirrorless fade gates clip for clip like its mirrored twin; a mirrorless one-frame cut gates on the no-takes path; the presence fallback order; same-frame ties in authored order; a fade that cuts to zero on one frame is absent from the cut; an opacity overlay gates end to end) and 1 rewritten (the preview's opacity track carries only its ramp) -- all red first except the overlay guard, which pins the path the synthesized mirror used to carry.

  • 2026-09-14 -- Export Playblast has no keyboard shortcut, and a test fails any key two handlers claim (net_utils/preview/scripts/playblast.js, viewer.html). One r press toggled real scale AND opened the recording prompt. The page acts on a key first and then hands the same event to every script (emit('key', event), last on purpose so a script can never shadow a page shortcut), so a script binding a page letter adds a second action instead of replacing the first. Playblast loses the shortcut rather than moving to a free letter (maintainer call): the button is the one way in, and its tooltip stops advertising (r). inspect (i) and turntable (t) never collided. test_preview_server +2: test_no_two_handlers_claim_the_same_shortcut gives every key a keyboard handler of the page or a packaged script compares against one owner -- read in either quote, under any parameter name and through a switch, a code counting as the key it presses (code === 'Space' is key === ' '); red against the shipped r pair, and it refuses to pass for a handler it reads no key from -- and test_the_shortcut_reader_sees_every_spelling pins that reader on each spelling.

  • 2026-09-14 -- the WebXR page's Export Playblast prompt offers quality presets, and the default records at 2560 px instead of 1280 (net_utils/preview/scripts/playblast.js, viewer.html, playblast.py). Every recording was a 1280 px long edge at CRF 16, with no way to ask for more short of editing MAX_EDGE. The prompt now has a Quality picker -- Draft (1280 px, CRF 23), Standard (1920, CRF 20), High (2560, CRF 16, default), Maximum (3840, CRF 16) -- remembered for the tab. The frame is rendered at the preset's size rather than upscaled: a smaller view has its pixel ratio raised for the recording (clamped to the GPU's MAX_RENDERBUFFER_SIZE/MAX_VIEWPORT_DIMS) and restored once the last frame is in. PreviewPlayblast.begin takes quality (0-100, refused otherwise before a frame is sent; None keeps the recorder's own) and finish encodes with it. viewer.showDialog gains a 'choice' field type. The saved status line states the written size. test_preview_playblast +3, test_preview_viewer_live +1.

  • 2026-09-14 -- GLB skins: every conversion drops the skinning data no node binds and re-points each skeleton at its joints' common root, ExportVerifier fails a skeleton that is not one, and fbx_to_glb replaces its output only after FBX2glTF succeeds (file_utils/mesh_convert/_mesh_convert.py, export_verify.py, fbx_media.py, file_utils/_file_utils.py). The Khronos glTF-Validator rejected a production assembly with SKIN_SKELETON_INVALID x7: FBX2glTF names a skin's first joint as its skeleton, and a sheared-chain flatten re-parents the joints side by side under their group -- three.js never reads the field, and check_glb_skins had no gate for it. MeshConvert.fix_glb_skin_skeletons re-points an invalid skeleton at the closest common root (or removes it where the joints share none); MeshConvert.prune_glb_unused_skins drops skins no node references and JOINTS_n/WEIGHTS_n on meshes no skinned node instantiates (that file: 255 of 262 skins, 836 meshes, 0.49 MB) and repacks the BIN. fbx_to_glb(overwrite=True) deleted the existing GLB BEFORE a conversion that runs 13 minutes on that assembly, so a failed or timed-out one cost the file it was to replace; FBX2glTF now writes into a scoped TempArtifacts store whose GLB replaces dst on success through the new FileUtils.atomic_write: copied to a sibling .part and swapped in by one os.replace, because a move copies straight over dst whenever the store sits on another volume (and on Windows on any volume, whose rename refuses an existing file), so a disk-full or I/O error part way through truncated the deliverable. FbxMedia's .part writer is the same primitive, promoted by its caller once the mmap closes. A destination another process holds open (FileUtils.is_locked) raises PermissionError before the conversion rather than at that replace. The prune leaves a primitive whose KHR_draco_mesh_compression extension names its attributes a second time. test_mesh_convert +7, test_export_verify +1, test_file +4.

  • 2026-09-14 -- ExportRun: the GLB key tolerance rides Optimize Keys, and a GLB-only run drops the FBX-side key hygiene (core_utils/export_profile.py); the GLB texture pass starts its longest encode first, and clip_spans can ask per window (file_utils/mesh_convert/_mesh_convert.py). glb_key_tolerance is now the GLB half of ONE choice: FBX2glTF resamples every frame, so the DCC-side optimisation never reaches the GLB and the tolerance is inert (a note says so) while optimize_keys is off; the table's OFF reads Keep Every Key, and SECONDARY_MAX_SIZE_OPTIONS' sentinel Same As Other Maps. With output_format == "glb" the FBX is a temp intermediate the converter resamples per frame, so FBX_KEY_HYGIENE (optimize_keys, snap_keys_to_frame, tie_all_keyframes, check_floating_point_keys, check_untied_keyframes) is popped with a note -- Optimize Keys alone was 98 s of a production run (plus SmartBake's own pass, which follows the level) for keys the deliverable never sees. optimize_glb_textures orders its pool longest-source-first (_largest_first): fed in insertion order it ran a 260 s UASTC+RDO encode alone at the tail of a 302 s pass. MeshConvert.clip_spans(key_spans=) is the same question asked per window -- the whole timeline, then each valid take -- for a DCC that can seek its curves instead of handing over every key time (listing a production bake's millions cost mayatk 10 s a pass); same take parsing, same answer. Ktx2Encoder sizes each toktx budget from the file it encodes (AUTO_TIMEOUT default, encode_timeout: the 300 s floor, or 60 s per megapixel): the flat 300 s shipped two 4K UASTC+RDO normal maps as PNG on a loaded host (260 s on a quiet one). Breaking: Ktx2Encoder(timeout=) defaults to AUTO_TIMEOUT instead of 300, so .timeout reads -1.0 unless a caller passes a number; the budget one encode gets is encode_timeout(width, height). test_export_profile +2, test_mesh_convert +2, test_ktx2_encoder +1 (and its STEP test's lambdas are defs).

  • 2026-09-13 — GLB sampler evaluation: a STEP value holds FROM its key (file_utils/mesh_convert/glb_clips.py). GlbClips._evaluate -- behind GlbKeyReduction.evaluate / deviation and the clip cutter's boundary pins -- found the upper key with bisect_left, so a time that IS an interior key paired that key with its predecessor: LINEAR still read the key (u == 1), but STEP held the previous value and read every gate one key late at its own change point. Surfaced as a 1.0 deviation between two IDENTICAL production gate samplers in an accuracy check; the deliverables were never affected (_window pins a bound only when no key sits on it). bisect_right now; test_mesh_convert +1, test_glb_key_reduction +1 and the STEP dedupe test pins its change-point values absolutely.

  • 2026-09-13 — GLB deliverables: a secondary-map ceiling, UASTC RDO and tolerance-bound key reduction, all through the shared web-delivery policy (file_utils/mesh_convert/glb_key_reduction.py, _mesh_convert.py, glb_pipeline.py, img_utils/ktx2_encoder.py, core_utils/export_profile.py). MeshConvert.optimize_glb_textures takes secondary_max_size -- a lower longest-edge ceiling for the packed data maps alone (SECONDARY_SEMANTICS: metallic-roughness / occlusion; color and normals keep the primary) -- and uastc_rdo, the --uastc_rdo_l lambda for every UASTC encode, a normal map capped at UASTC_RDO_NORMAL_MAX (0.75, toktx's own guidance); Ktx2Encoder models the flag (constructor default + per-call override, toktx's 0.001-10 range enforced) instead of leaving it to extra_args. Both are keys of web_delivery_texture_params (policy: off), so every producer inherits the same defaults. New GlbKeyReduction (MeshConvert.reduce_glb_animations, a GlbPipeline.build(key_tolerance=...) stage between the conversion and the texture pass, its summary the report's animation: None without a tolerance, and on a .glb source, whose report carries every key a build's does): FBX2glTF bakes a key on every frame, and each LINEAR sampler now keeps only the keys that reproduce every original sample within the bound under the viewer's own interpolation (Ramer-Douglas-Peucker; shortest-arc slerp for rotations, sign-agnostic), STEP samplers drop repeated values losslessly, CUBICSPLINE and shared outputs are refused, and both ends of every sampler stay so no clip changes length or origin. Measured on a 4K production assembly: 2.18 M keys, 6.8% needed within 1e-4 (0.1 mm / 0.006 degrees), 23.5 MB of animation. ExportRun carries the three modes (secondary_max_size, uastc_rdo, glb_key_tolerance; from_tasks pops and coerces them) and ExportProfile the combo tables (SECONDARY_MAX_SIZE_OPTIONS, UASTC_RDO_OPTIONS, GLB_KEY_REDUCTION_OPTIONS). test_glb_key_reduction (new, 10), test_mesh_convert +4, test_ktx2_encoder +1, test_export_profile +1, test_glb_pipeline +2. Found alongside and fixed: compact_glb_animations re-pointed a collapsed output accessor to its fresh two-key view without clearing a non-zero accessor byteOffset (legal glTF -- a packer sharing one view between accessors), so such a channel read past the payload it had just been given; the offset is cleared now, as the reduction pass does (test_mesh_convert +1, failing first).

  • 2026-09-13 — the Scene Exporter's shared contract grows a per-run value object, the shared tables and the texture staging pass; TaskFactory drops its set_/revert_ pairing (core_utils/export_profile.py, core_utils/task_factory.py, core_utils/engines/textures/map_optimizer.py). ptk.ExportRun is the frozen dataclass of ONE export run's modes -- output format, texture file type (+ KTX2 fallback), the two write-back flags, the size dial, the template, the verification row, the task-derived Optimize Keys level and relative-paths flag, the deliverable path and versioning -- with from_tasks(tasks, texture_file_types), the one copy of the parse both DCC exporters applied inline (legacy create_glb / glb_texture_format / optimize_textures_write_back spellings included; an unknown texture file type comes back as an error note, an unknown output format as a warning and FBX). ExportProfile now also owns the pipeline's declarative tables -- TASK_ORDER, the phase tuples and CHECK_DEPENDENCIES (verbatim from mayatk), the combo tables (OUTPUT_FORMATS, EXPORT_MODE_OPTIONS, TEXTURE_OUTPUT_OPTIONS, ANIMATION_OUTPUT_OPTIONS, TEXTURE_MAX_SIZES, BAKE_RANGE_OPTIONS, ANIMATION_CLIPS_OPTIONS, OPTIMIZE_KEYS_OPTIONS, and the registry-derived optimize_textures_options() / texture_file_type_options() / frame_rate_options()) -- plus the scoped_tables class decorator that hands a manager the tables scoped to the tasks and checks it implements and unimplemented(manager), which names the rest (the parity gap a mirror declares). MapOptimizer.stage_maps(sources, assess, ...) is the file half of both exporters' Optimize Textures task: judge each source (recompressible-but-optimal PNGs included), claim every output name serially (same-named maps from two folders stage into altN; a write-back collision skips the loser), encode in parallel, re-judge a durable staged copy reused from an earlier run and redo it when stale, discard a re-encode that saved under RECOMPRESS_MIN_SAVING; its report names, per source, the file to point the consumers at -- the DCC only repoints. The staging dir may be a callable resolved once something is pending, so a pass with nothing to stage creates no directory. Breaking: TaskFactory no longer pairs set_<x> with revert_<x>, so a task's revert_<x> is no longer called for it -- a task whose state must be put back stages that with stage_deferred_restore (or stage_deferred_context). That revert fired when run_tasks returned, BEFORE the work the tasks prepared for, so every shipped set_ task had to disarm it -- the deferred-restore registry is the one undo mechanism, and _manage_context lost its state bookkeeping. test_task_factory retargets the revert cases (test_export_profile +11, test_map_optimizer +5; 195 pass across the three).

  • 2026-09-13 - Color.linear_from_srgb / Color.srgb_from_linear (core_utils/color.py). The scalar twins of ImgUtils.srgb_to_linear / linear_to_srgb (which take images and arrays), for a widget that shows a LINEAR channel value -- a DCC colour attribute, a glTF factor -- on a display: the first three components convert per IEC 61966-2-1, alpha passes through, and an HDR value past 1.0 is not clamped. Measured need (2026-09-13): the Render Effects option box painted linear highlight colours as display colours, so the WebXR page, which encodes them, read much brighter than the box. test_color +4.

  • 2026-09-13 — ExportVerifier reports what each GLB image costs, and MapOptimizer.optimize_maps encodes a batch of maps in threads (file_utils/mesh_convert/export_verify.py, core_utils/engines/textures/map_optimizer.py, .../_mesh_convert.py, .../fbx_media.py). New gate glb_image_bytes lists the GLB's embedded images by size (read from the JSON chunk's bufferViews; nothing is decoded), largest first, and WARNs past a new ExportVerifier(max_image_bytes=) bound; it never FAILs, since the file already shipped. It measures the number a source-texture size limit only stands in for: a production 57 MB source PNG shipped as a 3.12 MB KTX2. MapOptimizer.optimize_maps(requests, workers) is optimize_map over many maps in a thread pool -- one (written, error) per request, in request order, a raising map costing no other its result -- for the scene exporters' Optimize Textures pass, which now re-encodes every shipping PNG (a 4K map is 1.6-7 s on its own). Its thread count is the new ImgUtils.encode_workers() -- ENCODE_WORKERS (8) capped by the core count, or a requested count verbatim -- the one memory-capped encode policy the GLB texture pass, FbxMedia's embedded-image rewrite, this batch and the DCC exporters' passes now share; MeshConvert.OPTIMIZE_WORKERS stays one release as a deprecated, read-only alias of ImgUtils.ENCODE_WORKERS and goes in the next (assigning it no longer changes the pass: set ImgUtils.ENCODE_WORKERS, or pass workers=). MapOptimizer.RECOMPRESSIBLE_FORMATS / RECOMPRESS_MIN_SAVING / is_recompressible name the containers a plain re-encode can shrink (PNG) and the saving that makes one worth shipping (5%). test_export_verify +1, test_map_optimizer +2, test_mesh_convert +1 (the alias).

  • 2026-09-13 - a name pattern can carry a version counter, and a version scan can cover a multi-file deliverable (str_utils/_str_utils.py, file_utils/_file_utils.py, file_utils/mesh_convert/export_verify.py). StrUtils.resolve_name_pattern(keep=...) leaves the placeholders a later stage owns ({n:03d}) verbatim -- not filled, not reported unresolved, and not legalized, where the : in the spec used to be dropped as illegal -- and returns template (the name as a str.format template, literal braces doubled so a brace in a scene name cannot read as a field) and kept. FileUtils.next_version_number(directory, format, stem, ext, start, extensions) is next_version_path's scan on its own, for a name that is a whole template with the counter anywhere; both take extensions, sibling extensions whose numbers count as taken, so an .fbx + .glb deliverable versions as ONE and a GLB-only one finds its own .glbs. A repeated {n} is now a backreference instead of a duplicate regex group (which raised). ExportVerifier's sidecar discovery falls back to a versioned deliverable's SERIES manifest (.asset.scene_data.json for asset_v003.glb), the one the scene exporters write; it looked for the file's own stem alone, so the dropped-take gates SKIPped for every versioned deliverable checked from disk. test_str +5, test_file +3, test_export_verify +3 (206 / 151 / all pass). ExportProfile owns the Scene Exporters' Output Filename rule (core_utils/export_profile.py) -- NAME_WILDCARD, VERSION_TOKEN ({n}), OUTPUT_EXTENSIONS (the files each output format ships), resolve_output_path (pattern + counter + those files -> path / paths / n, a counter spec an int cannot take numbered plainly), fold_legacy_naming (the retired Version pattern / Timestamp flag, for one release), strip_deliverable_extension and naming_report (diagnostics as (level, message)) -- so mayatk and blendertk resolve through one copy instead of two. test_export_profile +5.

  • 2026-09-13 - a WebXR push can preview an effect the scene does not carry, and the render-effect key shapes are planned once (math_utils/ramp_keys.py, file_utils/mesh_convert/_mesh_convert.py, .../glb_pipeline.py, net_utils/preview/bridge.py, .../deliverer.py). New RampKeys (pulse, fade, fade_loop, pulse_gaps, frames) is the pulse/fade plan mayatk's and blendertk's writers each carried a copy of. New PreviewBridge.push(data_export=...) -> GlbPipeline.build(data_export=) -> MeshConvert.fbx_to_glb(data_export=) overlays in-band channels for that build only (MeshConvert.overlay_data_export, applied ahead of every pass that reads one; None clears a channel), and MeshConvert.effect_preview_channels states one render effect as that overlay: the scene's tracks replaced, the take list and shot record cleared, one clip over the ramp's own extent. The build report, GlbPipeline.build's result and the deliverer's (what push returns) carry data_export, the channel keys the overlay replaced ([] when none landed), so a caller can tell an applied overlay from one a stale bridge never forwarded -- measured 2026-09-13 as a preview that looked the same whatever the panel was set to. A caller that passes no overlay builds exactly as before.

  • 2026-09-13 - FileUtils.get_classes_from_path no longer imports a file that defines no class (file_utils/_file_utils.py). It parsed each file for classes and then imported it regardless, and importing runs the module. Switchboard slot discovery scans whole package trees with it, so a class-less script inside a package executed at every DCC start: a scratch probe left in mayatk/mayatk/test/temp_tests/ launched a fresh Maya on import, whose own startup scan imported it again -- a new, hung Maya every ~20 s. Results are unchanged (a file without a class never yielded a row).

  • 2026-09-12 - a KTX2 web deliverable ships without PNG/JPEG fallback twins (file_utils/mesh_convert/_mesh_convert.py, net_utils/preview/deliverer.py). MeshConvert.web_delivery_texture_params now carries ktx2_fallback=False (WEB_DELIVERY_KTX2_FALLBACK), and optimize_glb_textures defaults to it. Breaking: optimize_glb_textures(ktx2_fallback=) now defaults to False where it was True, so a caller that counted on the default for the twins passes True. The twin -- a PNG (normals, ORM) or JPEG (colour) copy of each KTX2 image, read only by stock importers -- was left to each producer, the scene exporters kept it, and a production 4K GLB carried 145.8 MB of twins beside 123.1 MB of KTX2: 48% of a 302.6 MB file, 156.9 MB without them. Behaviour that moves: KHR_texture_basisu (and, for a KTX2-mode lightmap, EXT_texture_webp) lands in extensionsRequired, so the GLB needs a basisu-capable viewer; a caller whose GLB must open in Blender or Unreal asks for the twins -- web_delivery_texture_params(ktx2_fallback=True) (the policy's third override, None = policy like the other two), which the scene exporters offer as the KTX2 + PNG/JPEG Texture File Type. The preview deliverer's own override is gone (the policy says it now); drop_glb_texture_fallbacks stays the retrofit for a GLB built with twins.

  • 2026-09-12 - MeshConvert.compact_glb_animations deletes the time inputs its collapsed channels stop reading. A clip rebuild gives each channel a time accessor of its own over a shared view, so rewiring a constant channel to the clip's two-key input stranded the old one: 15,843 unreferenced accessors (1.59 MB of JSON, each a validator UNUSED_OBJECT) in the production 4K GLB. The deletion is _release_animation_payload's, shared as _drop_orphaned_accessors (same renumbering, same refusal under extensions that hold accessor indices of their own).

  • 2026-09-12 - ExportVerifier states a declared extension requirement in glb_extensions instead of warning on it in glb_envelope. The envelope gate turned every verify_glb note into a WARN, including the "requires a reader supporting ..." prerequisite that glb_images already PASSes as the declared contract -- a standing warning on every WebP deliverable, and on every KTX2 one once the fallback twins went. verify_glb's own report is unchanged; the note has one definition (MeshConvert._requirement_note) that both read.

  • 2026-09-12 - the GLB build no longer makes FBX2glTF bake every shot twice (file_utils/mesh_convert/fbx_media.py, .../fbx_file.py, .../glb_pipeline.py). FBX2glTF evaluates every node at every frame of every take, and a Maya take split writes each shot as its own stack beside the whole-timeline one -- which the clip rebuild then slices, discarding the converter's split takes. GlbPipeline.build now reads the FBX's own fbx_takes channel (FbxFile.user_properties) and, while a stack those names do not cover survives, converts a scratch copy without the declared takes (FbxMedia.drop_takes: each named AnimationStack plus every layer/curve node/curve only dropped owners reach, their connections, Takes entries and Definitions counts; everything else byte for byte). Measured on a production assembly (18 shots, 2495 nodes): conversion 1377 s -> 537 s, converter input 491 -> 385 MB; the whole-timeline animation came out identical (1203/1203 channels, 19.4M values, NaNs in place), as did all 3388 mesh/skin accessors, the node TRS and the materials. A failure is a warning and the original is read. Behaviour that moves: if the clip rebuild later declines, the GLB carries the whole-timeline stack alone rather than the converter's split takes. FbxMedia.downsize and drop_takes now share one tracked .part writer.

  • 2026-09-12 - reduce-to-extremes math vectorized (math_utils/_math_utils.py). fit_hermite_slopes built its hold test and normal equations with a numpy call per SEGMENT (130 s of a production extremes pass); both are now whole-array (reduceat / bincount) and the Thomas solve runs on plain floats. reduce_samples' refinement finds each segment's worst sample in one reduceat. Output identical to 1.3e-16 relative over 300 randomized curves (holds, rounding, 3-3000 samples), 2.5x faster in isolation. keep_indices out of ascending order now raises ValueError instead of producing garbage.

  • 2026-09-12 - MeshConvert.clip_spans bisects the sorted key times instead of filtering every key once per take: 23 s of an 18-take production export (millions of baked keys, two calls per export), now negligible.

  • 2026-09-12 - TaskFactory records _last_skipped_checks beside _last_skipped_tasks. A caller that proceeds past a failed check (the exporters' override) must not report the checks the abort dropped as passed -- they never ran.

  • 2026-09-12 - GlbPipeline.build delivers the GLB beside the caller's FBX when a payload stage swapped the converter's input. With no dst, fbx_to_glb wrote beside whatever it read, so once the split-take strip (or a downsize) handed it a scratch copy, a production export's GLB landed in the temp dir and never reached the export folder.

  • 2026-09-12 - MeshConvert.fbx_to_glb sends the FBX SDK's embedded-media extraction to a temp store of its own (--fbx-temp-dir, removed when the call returns; a caller's own --fbx-temp-dir in extra_args wins). The SDK extracted every embedded texture into <stem>.fbm beside the input: 311 MB beside a production export in its synced folder, and 311 MB per export in the temp dir beside a pipeline scratch copy. Maya's own FBX export writes no .fbm (probed).

  • 2026-09-12 - MapFactory.prepare_maps returns a parallel batch in the sets' own order. Results were collected in completion order, so with max_workers > 1 a caller iterating the batch got whichever order the workers finished in -- different from the serial branch, and from run to run.

  • 2026-09-12 - The embedded scene sidecar's texture map follows image renumbering. prune_glb_unreferenced_textures renumbered images without the envelope's textures map, and the highlight pass prunes AFTER that map is recorded: on a production GLB 28 of 35 references named the neighbouring image, their digests stamped against those wrong bytes so verify_glb passed them. A pruned image's references now go (the envelope's validate count follows), one that named no image is left for verify_glb to report, and dedupe_glb_images points a dropped twin's references at its survivor.

  • 2026-09-12 - a user-typed NAME pattern, resolved once for every tool that offers one (str_utils/_str_utils.py). A field that names one output file wants a grammar, and every tool that grew one grew its own: the Scene Exporter's Output Filename read an asterisk as a glob against the disk, its Version field read {tokens}, and neither could say what the other accepted. resolve_name_pattern is the whole of it, composed from three primitives rather than written as one: expand_wildcard rewrites the bare wildcard into placeholder form, resolve_placeholders substitutes and reports, to_legal_filename drops what an OS will reject. The wildcard is POSITIONAL, which is the difference from find_str_and_format's asterisk -- that one is a mode flag, so it reads *_GEO as "suffix" and can express only one affix at a time; this one is a slot standing for the default value, so WIP_*_export wraps a name in a single pass and a blank field and a bare * are the same thing. It is deliberately the ONLY wildcard: a name resolves to one value, so a match-anything glob has nothing left to mean, and every other filename metacharacter is illegal on Windows anyway. Diagnostics come back as data -- unresolved, dropped, error -- rather than through a logger, because the two callers report them at different severities and one of them is a tooltip; the same call also hands back the expanded form so a live preview resolves the very string the write will. name_pattern_context values the tokens no host is needed for (date, time, user) and samples them ONCE, so a pattern using several cannot straddle a tick; a tool adds only what it alone can value. to_legal_filename is the permissive counterpart to to_legal_name, which is an identity rule and would mangle asset-v2.hero into asset_v2_hero; this one removes only <>:"|?*, and reports each so a caller can say what it took. A pattern whose every character is dropped falls back to the default rather than returning empty -- a lone ? would otherwise have a caller join nothing onto a directory and write a file that is only an extension. A malformed pattern (a lone brace) is legalized too: its braces read as literal text and its illegal characters are dropped and reported, where it came back verbatim for Windows to refuse at the write. Two fixed while adjacent. replace_placeholders raised IndexError out of the formatter on a POSITIONAL field ({} / {0}), which it has always done and which the Version pattern could already reach: this takes keywords only, so a positional field has no value and is now preserved like any other missing key -- every caller of this resolves text a USER typed into a pattern field, where a stray brace pair is a typo, not a reason to abort an export. And find_str_and_format's replace_prefix returned the string untouched when there was no filter text to drop, where replace_suffix in the same position appended -- the asymmetry contradicted that method's own Note, so GEO_* with an empty filter now prepends the way *_GEO has always suffixed. test_str +16. Full suite: 4559 passed.

  • 2026-09-11 -- a render-effect channel's colour is a RAMP with two ends, and each end carries its own default (core_utils/color.py, file_utils/mesh_convert/glb_fades.py, .../_mesh_convert.py). The highlight channel is one keyable float and one colour, so its dim half was not a colour at all -- it was the absence of one, and an artist asking for the opposing half of a pulse had nothing to set. ColorStops names the two ends (hi, lo) as whatever string the consumer addresses a colour by, since the two sides of the glTF join spell the same concept differently -- Maya attribute names on one side, published track keys on the other. One object rather than two optional fields, because the stops are not independent: a lo without a hi is meaningless, and -- the part that would have shipped a silent regression -- the two ends need DIFFERENT fallbacks. A channel publishing no colour reads WHITE so the ramp still shows, which was the old DEFAULT_COLOR; hand that same white to an unstated LOW stop and every asset authored before the stop existed inverts on its next export, pulsing from white down to its colour. The defaults now live beside the stops they belong to instead of as one module constant a caller picks, so there is no call site that can pick the wrong one. _highlight_values lerps between the two ends over the material's own emissive rather than scaling one, and the legacy shape falls out of it exactly: a low stop of black IS base + hi * sample, which is why ColorStops.BLACK is what an unstated low stop resolves to. resolve() reads the shapes producers actually hold -- None, a bare (r, g, b) meaning the high end alone, or one triple per stop -- so a producer written before a channel grew a second stop stays correct with no alias and no version check, and a malformed entry falls back rather than raising, because a colour is lookdev and refusing to publish a whole scene over one bad triple trades a wrong tint for no deliverable. The flat-versus-pair test is on the THREE NUMBERS, not on value[0]: sniffing the first element alone misreads [None, (r, g, b)] -- a pair whose high end was never published -- as a flat triple and hands the low colour to the high end. Breaking: PointerChannel's color_key field is now color_stops (a ColorStops), so PointerChannel(color_key=...) raises TypeError (no caller outside glb_fades.py constructs one); PointerChannel.color_key survives only as a deprecated read-through of the high stop, for one release. Color also gained hsv/from_hsvf, both documented as a read-out rather than a working representation: the 8-bit store quantises and hue is undefined as value approaches zero, so a slider that round-trips a drag through them walks the hue off and returns WHITE from a blue dragged to black and back (measured). Anything that EDITS a colour has to keep its own float HSV state. test_color +15, test_mesh_convert +3 -- including the compatibility pin, which asserts a track with no dim stop ships byte-identical output to one that states black. Full suite: 4544 passed.

  • 2026-09-11 -- the WebXR preview asks before it records, and can burn in the shot DESCRIPTION (net_utils/preview/scripts/playblast.js, net_utils/preview/viewer.html). The burn-in was a toggle sitting beside the record button, which is exactly the kind of state that gets left on and found in the movie afterwards -- and the annotation is drawn into the pixels, so there is no taking it out again. It is now asked at the moment the file is written: Export Playblast opens a prompt that states what it is about to write (clip, frame count, rate -- the clip is the picker's to choose and the rate is the deliverable's, so the prompt can only show them) and offers the two burn-ins. Answers are remembered for the tab, because a reviewer annotating one shot is about to annotate the next one the same way, and are SNAPSHOTTED onto the job at start: a recording is one thing, and half of it stamped is not a state this can reach. Cancel resolves to null and writes nothing, and a push landing while the prompt is up DISMISSES it -- every dialog this page shows is about the deliverable on screen, so a model swap invalidates the question; the page closes it itself rather than each script having to, and it resolves as a cancel, which is the safe answer to a question whose subject has gone. The new burn-in draws the note the DCC's Shots panel carries -- already in extras.animation_web as description, and the half of a shot record a reviewer watching the movie cannot otherwise see -- on the line ABOVE the name, so adding it pushes the name up rather than moving the frame counter off the edge the two corners are measured against. The two options are INDEPENDENT rather than nested, which is what lets the prompt ask them as two plain checkboxes with no dependent-field logic: neither ticked means no pad and nothing drawn, and the description alone lands on the foot of the frame. A shot stating no description records without one; holding a blank line open for it would be a burn-in that says nothing. Left text is now elided with an ellipsis against the counter's measured width -- a description is prose and can be longer than the frame, and text running off it reads as a corrupted burn-in rather than as a long note; the trim estimates proportionally first (the font is monospaced, so the estimate is usually exact) rather than costing a couple of hundred measureText calls on every frame. Two new pieces of viewer script API, both general rather than shaped for this one caller: viewer.showDialog({title, fields, confirm}) is the page's ONE modal, resolving to {key: checked} or null -- a script asks for options through it rather than through window.confirm for the reason it adds buttons through addButton rather than createElement, one look and one place -- and it takes the keyboard whole while open, since Space is a checkbox in there and would otherwise also pause the transport behind it, and leaves the page behind it inert until it closes (swallowing the shortcuts alone left Tab free to walk focus out to the clip picker, where an arrow key changed the clip the prompt names); viewer.descriptionAt() answers for whatever the model is posed at, on a single clip as well as on the sequence (unlike shotAt, whose reader is the readout the picker already duplicates). test_preview_viewer_live +4, driving the prompt as a user does rather than reaching into the script's state: the description reaches the PIXELS, a shot with no description leaves the frame alone (the pair is what makes either meaningful), and a cancelled prompt writes no movie and starts no recording.

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.