- 2026-10-12 -- Shot Manifest: Assess measures a step's unused tail where the build keys (
core_utils/engines/shots/manifest/manifest_engine.pyShotManifest._compute_content_end). The content end (StepStatus.shrinkable_frames) re-placed each behavior's blocks by hand -- anendanchor at the range's end, anything else from its start -- so it ignored the spread the build keys an object's point behaviors at (Behaviors.anchor_overrides, "the one placement rule"): an object with two fade-ins keys the second at the shot's end, and Assess read the shot as ending at frame 15 of 100 -- 85 frames "shrinkable" over the very keys. It now takes each behavior's last key fromBehaviors.key_windowat its spread anchor (an effect placed by the recipe, which needs no frame rate), the placement the appliers and the verifiers already share; the unusedfpsparameter is gone.- Test(s):
test_shots_manifest_core.pynewTestContentEndPlacesLikeTheBuild(red: 15.0 != 100.0); the manifest modules 656 passed.
- Test(s):
- 2026-10-12 -- Shot sequencer: a band move, a slide or a Shift edge that closes a gap holding the animator's keys keeps that seam as a straight cut and retimes the keys into its frame, as a respace does (maintainer 2026-10-12, "same as respace", "same rule";
core_utils/engines/shots/shot_sequencer.py_closing_slide_startand_closing_edges(retime=)(keep=_seams_hold_keys),_slide,set_shot_start,set_shot_edge, new_retime_closed_gap,_retime_closing_gapand DCC hook_retime_closing_gaps). The gap's keys belong to the still shot before it, which a slide does not carry. The slid shot landed over them and played them: A [0,60] still, B [100,160] moved to 60 with keys 90:0.5, 100:0, 110:1 played B 0.5 where it played 1.0, and A 0.5 off. The poses both bounds play are pinned first, then the keys are retimed into the kept frame before anything moves. Move to Shot's landings close no gap: they only decide a seam already flush. A Ctrl edge keeps its keys by contract, the edited shot adopting them with nothing else changed. A Shift edge writes its shot's keys across the new span, so it was written across the gap's keys and played them, and the shot beside it changed (A 0.0 for 0.5). It now stops a frame short where the gap holds keys and retimes them into that frame (the poses are not asked: a retime keeps its exemption from the poses rule). An end grown onto the next shot carries its tail into the frame before that shot's start.- Test(s): blendertk
test_shot_sequencer.py"a band move / slide / start closing a keyed gap keeps a frame" (3, red: A 0.5 / B 0.5 off) and "a Shift start / end edge closing a keyed gap" (2, red: the shot laid across the key); mayatktest_sequencer.pyTestOrderedRippleClearsUnownedKeysnow holds the decided rule (3 subtests, red: A 0.5 off) and newtest_a_shift_edge_closing_a_keyed_gap_keeps_a_frame_the_key_in_it(2 subtests, red).test_shot_sequencer.pytest_the_seams_kept_as_straight_cuts_are_reported(wastest_the_seams_left_apart_are_reported) reads the info line's new words.
- Test(s): blendertk
- 2026-10-12 -- Shot sequencer: a respace to 0 keeps a seam as a straight cut where the gap holds the animator's keys (
core_utils/engines/shots/shot_sequencer.pyrespace,_close_flush_seams(keep=), new_seams_hold_keysand DCC hook_gaps_keyed). The closing rule closed such a seam flush when its two poses agreed. The retime refuses a zero-width gap, because it would cut or stack the keys, so they stayed where they were and the next shot played them. SOL_REPLACE_ASSEMBLY pulled into Blender with its stepped keys played A04 0.0012 off after a respace to 0. Kept as a straight cut, the keys are retimed into its frame. The info line naming the seams kept apart now says what they are: "N seams kept as straight cuts (poses differ): A01_1|A01_2, ...; (gap holds keys): ..." -- no empty frame sits between the shots, the gap hold steps the transition (maintainer 2026-10-12;_report_straight_cuts).- Test(s): blendertk
test_shot_sequencer.py"a respace to 0 keeps a frame where the gap holds a key" (red: B 3.69 off); mayatktest_sequencer.pytest_respace_to_zero_keeps_a_frame_where_the_gap_holds_a_key(red: flush at 40).
- Test(s): blendertk
- 2026-10-12 -- Shot Manifest: a sheet's mapping-template options live on the scene, beside its Local Edits (
core_utils/engines/shots/shot_model.pynewShotStore.manifest_options;manifest/manifest_edits.pynewManifestEdits.sheet_options,set_sheet_options,same_sheet, andadopt_layersmoves the options with the edits and tags;manifest/mapping/_mapping.pynewMapping.infer_options). A template's options (Object Case and the rest) were kept per user, so a scene reopened on its sheet under other saved options read the sheet another way than it was built. SOL_REPLACE_ASSEMBLY was built with Object Case = upper: with the user's options empty, 1 of its 20 built steps read back as built, Assess reported 24 objects missing, and all 5 Local Edits were stale. Maintainer 2026-10-12: options are kept per sheet, on the scene, keyed byManifestEdits.layer_keyso a moved sheet keeps them. The user's saved options only seed a sheet the scene has never read. A scene built before this record has none, soinfer_optionsreads the options back from what its Build kept (csv_objects): a coordinate search over the template's options that keeps the values matching the most steps' objects. On SOL it findsobject_case="upper"in 0.15 s, and 20 of 20 built steps read back.- Test(s):
test_manifest_edits.pynewtest_the_sheets_options_live_beside_its_edits_and_move_with_them;test_shots_core.pynewtest_manifest_options_round_trip_and_read_tolerantly;test_shots_manifest_core.pynewtest_infer_options_reads_back_what_a_build_recorded.
- Test(s):
- 2026-10-12 -- Shot Manifest:
match="name_then_order"no longer order-pairs a shot split off a step (core_utils/engines/shots/manifest/manifest_engine.pyShotManifest.pair). The order pass took a split-off tail, in timeline order, as the shot of the next unbuilt step, even though the tail belongs to its own step's section. Maintainer 2026-10-12: a shot linked to its step (ShotStore.step_links) is left out of the order pass, and the other shots pair in order as before. On SOL, name pairing is unchanged (20 paired, 8 orphans, 8 links), andname_then_orderorder-pairs none of the tails.- Test(s):
test_shots_manifest_core.pynewtest_order_pairing_passes_a_split_off_shot_by, which fails without the exclusion: the tailA01_2pairs withA01b.
- Test(s):
- 2026-10-12 -- Hand-off manifest: a
stepped_keyssection (core_utils/handoff/manifest.pynewHandoffManifest.STEPPED_KEYS). Records{node: {channel: [frames]}}, the keys the source stepped. Blender's FBX importer makes every key LINEAR, so a Maya hold arrived as a ramp. mayatk writes this section on the Blender bridge's send and pull, and blendertk'sMayaSceneImportmakes those keys CONSTANT again.- Test(s):
test_handoff_manifest.pypins the section list.
- Test(s):
- 2026-10-12 -- Shot transfer: a curve several objects share crosses for each, and lands its claims once (
core_utils/engines/shots/shot_transfer.pyShotTransfer.encode(curve_refmay answer a list),decode). blendertk now keys a claim by curve identity (ledger format 2), so a linked duplicate's shared curve is one ledger key that two objects play.curve_refnames each, and the carrier, with one curve per object, gets the claims on every sharer. On the way in, objects whose curves are one here (a Blender linked duplicate, a Mayaduplicate -ic) each bring the claims, and they land once. A single(object, label)answer whose object is unnamed is still a curve left behind (read as a list, it raised aTypeError).- Test(s):
test_shot_transfer.pynewtest_a_curve_several_objects_share_crosses_for_each,test_objects_sharing_a_curve_here_land_its_claims_once(both red on the old codec: the sharer missing; the claims doubled),test_a_ref_naming_no_object_leaves_its_claims_behind(red: theTypeError).
- Test(s):
- 2026-10-12 -- Shot sequencer: a curve that runs on keyless from a shot's start pin across the gap after it is pinned on that shot's end and the next shot's start, so the gap holds and no ripple re-times it (
core_utils/engines/shots/shot_sequencer.py_enforce_gap_holds, new_through_pins,_keep_through_ends). The gap hold never steps a shot's start pin (_holdable_seams: it would hold the shot still). The crossing it opens therefore ran keyless from that pin to a key shots later, and every ripple past it re-timed the shots between. A curve no member keys made a trim play the next shot 1.2 off and a slide 0.16 off. On SOL pulled into Blender (whose pull ramps a Maya step, BACKLOG 2026-10-12), 23 of 27 band moves played the shots a ramp crossed up to 1.14 off. Each such seam now pins the shot's end, claimedstays(its own pose: a grow would otherwise carry it off and re-time the crossing, 0.44 off), and the next shot's start. The next pass holds the gap from there, one gap per pass, like any other. Behaviour change: those curves gain bound pins and gap holds. SOL numbers: mayatk's entry.- Test(s):
test_shot_sequencer.pytest_a_shots_start_pin_is_never_heldnow also expects the crossing's end and stop pins.
- Test(s):
- 2026-10-12 -- Shot ledger format 2: a step claim carries the halves its hold touched and knows its key by value + in-half (
core_utils/engines/shots/shot_ledger.pynewShotEditLedger.FORMAT_VERSION,source_version,StepClaim;record_step(in_half=, out_half=),claimed_step,is_same_key(in_half=, half_tol=),to_dict/from_dict/retime;shot_transfer.pyShotTransfer.encode). Maintainer 2026-10-11: one versioned format, and older ledgers load as today. A step claim may record the key's IN half as its hold froze it and its OUT half before the step, each as the DCC's own list of numbers (Maya[angle, weight], Blender a handle offset).claimed_stepreturns aStepClaim: the same(in, out, value)tuple, plusin_half/out_half. A claim with an in-half knows its key by value AND that half. The in side's type no longer counts: a Blender paste copies the easing, so a pasted step read as the hold's own (BACKLOG 2026-10-10). A re-posed held key is the animator's now. A format-1 claim keeps the old either-or rule. A payload carries"version": 2; one without it reads as 1 (source_version). The halves are slopes on one clock and one DCC: aretimeto another rate drops them, and aShotTransfersends the claim without them. Public API change: needs a minor bump.- Test(s):
test_shots_core.pynewTestEditLedgerFormat2(5; 3 red on the old ledger, and the empty-ledger and format-1 guards pass either way);test_shot_transfer.pynewtest_a_step_claims_halves_stay_home.
- Test(s):
- 2026-10-12 -- Shot sequencer: a leading pad keeps the shot's opening pose with the content it slides (
core_utils/engines/shots/shot_sequencer.pyadd_shot_space;_rename_seam_claims(stays=)). A leading pad slides the shot's content on, then puts its start back. The gap hold's stop pin on that start carried the content's opening pose. As the start's claim it then FOLLOWED the start back, so the shot's first segment played stretched. SOL real-scene harness: "A01_2" KEYS_translateY was 1.65 off over 16 frames, and it was already so on the 2026-10-10 release. The start's claims that rode with the content are now markedstays, so they are disowned where they landed rather than followed. SOL numbers: mayatk's entry. - 2026-10-12 -- Shot sequencer: Move to Shot keeps what stays in the source, and a run anchored on the destination's start keeps the shot before its close (
core_utils/engines/shots/shot_sequencer.py_send_sequences_to_shot, new hook_freeze_left_behind). A run leaving its source left keys behind whose derived slope came from the run, so they re-derived and the source played its own content differently up to its last stayed key (a tail run) or from its first (a head run). In a fixture the source was 0.29 off; on SOL "C02" 0.04, where BACKLOG had 0.099 from the closing pose (BACKLOG 2026-10-11). The new DCC hook holds those slopes before anything moves, a gap hold's stepped key excepted (the held tail). Separately, a group from after the destination whose objects have nothing there is anchored to the destination's start. On a flush seam that is the shot before's last frame, which then played the run's first pose: SOL gap 0, B11's first run sent to B10_2 played B10_1's last frame 98.35 off. The closing rule now applies there as it does to a head block: the destination opens a frame later where the poses differ (_head_landing_floor, read before anything moves). SOL numbers: mayatk's entry.- Test(s):
test_shot_sequencer.pyTestContentLeavingAShotKeepsItsNeighboursPosesnewtest_move_to_shot_holds_what_the_run_leaves_behind_first(red without the call);test_a_copy_leaves_its_source_and_pins_nothingalso asserts a copy holds nothing.
- Test(s):
- 2026-10-12 -- Shot sequencer: a plain edge edit, a trim or an extend pins what both shots play across the moved edge, and a flush shrink closes its seam under the closing rule (
core_utils/engines/shots/shot_sequencer.pyresize_shot_bounds,fit_shot_to_content, new_hold_edge_neighbours,_rename_seam_claims;shot_plan.pyShotPlanner.plan_ripple_upstream). An edge edit rippled the neighbour by the edge's delta with its keys while the shot's own stayed, so a curve crossing the edge keyless ran from a key that stayed to one that moved, and both shots played it re-timed. Flush, A [0,50] | B [50,100] with a ramp keyed at 30 and 80, A's end to 46: A 0.13 off, B 0.29. Across a gap the gap hold covers most curves, but never a curve whose last key before the gap is a shot's start pin: a ramp no member of the shot keys, trimmed, played the next shot 1.2 off. Every content curve is now keyed on both sides before anything moves (_pin_bound_poses, claimed): where the shot keeps playing to (its new bound for a shrink, its old one for a grow, its new one for an extend, whose ripple runs from there) and on the neighbour's facing bound. A flush grow stays the split's to pin. A flush shrink lands the neighbour flush where the two poses agree; where they differ it keeps the seam as a straight cut, a frame short (maintainer 2026-10-11, "ALL of them"), so the neighbour moves one frame less than the edge was dragged. There the old seam's sample rides with the neighbour, its claim re-named for it. The head edge is the mirror.plan_ripple_upstream(carry_gap=True)now widens the last moved shot's window to a bound past the pivot's start, the mirror of the downstream carry. Behaviour change: a gap-seam edit leaves those pins as keys. SOL numbers: mayatk's entry.- Test(s):
test_shot_sequencer.pynewTestAPlainShrinkClosesItsFlushSeam(7). The differing tail, the head and the claim re-name were red with the hold off; the gap shrink pins both sides, the gap grow pins the old end, and a flush grow pins nothing.test_shots_core.pynewtest_carry_gap_upstream_takes_what_a_head_shrink_gave_up(red on the old planner).
- Test(s):
- 2026-10-11 -- Shot Manifest: a sheet's Local Edits and colour tags are found by its path, else by its file name (
core_utils/engines/shots/manifest/manifest_edits.pynewManifestEdits.layer_key,adopt_layers,read,write;manifest_tags.pyManifestTags.read/assignfind their layer the same way). A layer was looked up by the sheet's exact path, so a sheet moved, or opened through another mount, found no layer and its edits stopped applying without a word. SOL_REPLACE_ASSEMBLY keeps the 5 renames that fix its sheet's object names under a personal Dropbox folder's path: its sheet copied elsewhere read 0 of them. Maintainer 2026-10-11: the exact path first (either separator, the platform's case rule), else the ONE layer saved under that file name, re-keyed to the new path on save, edits and tags together. Two layers of one file name are two sheets: a third path takes neither, so same-named sheets never share edits. A sheet read from a URL matches only its own URL, since two shared sheets' export links end alike. The copy now reads 5/5 in mayatk and in a Blender bridge pull.- Test(s):
test_manifest_edits.pynewTestTheScenesLayers(7),test_manifest_tags.pytest_a_moved_sheet_keeps_its_tags_and_a_pick_re_keys_them; with the exact-path lookup restored in a scratch copy, 4 of the 8 fail.
- Test(s):
- 2026-10-11 -- A shot split off a step's shot exports that step's section (
core_utils/engines/shots/shot_model.pynewShotStore.STEP_LINK,ShotStore.step_links,_export_view;shot_sequencer.py_split_at;manifest/manifest_model.pynewShotPairing.links;manifest/manifest_engine.pyShotManifest.pair). The split gave the tail a name and a description and nothing relating it to its step, so Unity'sShotMetadataController.sectionwas null for 8 of SOL_REPLACE_ASSEMBLY's 28 clips (A01_2, A03_2, B06_2, ...). Maintainer 2026-10-11: a non-binding step link. The split writesmetadata["step_link"](the head's binding, or its own link), never the binding itself: a step has one shot. A tail split before this is linked by its<step>_<n>name, one_<n>stripped at a time,<step>a step some shot is bound to. That is read when asked and never written, so opening a scene changes nothing. The export view takes a shot's own section, else that of the shot bound to its linked step;ShotManifest.pairreports the links of the shots no step pairs with. SOL: 28/28 clips sectioned, in mayatk and through the Blender bridge.- Test(s):
test_shots_core.pynewTestASplitOffShotIsSectioned(3),test_shot_sequencer.pytest_a_split_links_the_tail_to_its_heads_step,test_shots_manifest_core.pytest_a_split_off_shot_is_linked_to_its_step_never_paired; 4 of the 5 fail with the link reverted in a scratch copy (the fifth pins that reading writes nothing).
- Test(s):
- 2026-10-11 -- The shot record names only the members an export carries (
core_utils/engines/scene_export/scene_records.pySceneRecords.SHOTSisexport_scoped;core_utils/engines/shots/shot_model.pyShotStore.export_recordsreadsExportContext.scope, new hookShotStore._export_set_members). A selected or visible-only export'sshot_metadatalisted every member of every shot, nodes the file does not carry. Each clip now lists the members under the export roots, and every take still ships: a shot is a span of time. The scene keeps its whole record, put back after the write (ExportSnapshot.restore_scene_wide). Unity'sShotRecord.objectsis read as data only, so a shorter list breaks no reader. SOL, one group exported (SCREWS_GRP): 8 of 77 member entries, all 28 takes. Visibility and articulation records are still unscoped (BACKLOG).- Test(s):
test_shots_core.pynewTestAScopedExportListsWhatItCarries(2, both red with the scope read reverted);test_scene_records.pyassertsSHOTS.export_scoped.
- Test(s):
- 2026-10-11 -- A renamed member no longer leaves its HOLD shot stale (
core_utils/engines/shots/shot_model.pynewShotStore.member_ids,_capture_member_ids, hooks_member_ids/_current_names;to_dict/from_dict,_export_view). A hold shot keys nothing in its frames, so only its members say it is live, and members are stored by name. A rename the store was not told of (the Outliner, panel closed) made the shot stale, and the export left its take out. Each held name's node id is now saved with the store, an additivemember_idskey that an older reader ignores. A DCC store finds a member a rename left behind by it, read-only. The export ships that member under its name now; the store is not changed. An id that names no node (deleted, another scene's) keeps the old behaviour. The ids are the scene's own, so a hand-off leaves them home (ShotTransfer.encode), as it does a step claim's halves. SOL saves 42 ids; a renamed A07 member ships as renamed, all 28 takes declared.- Test(s):
test_shots_core.pynewTestRenamedMembersByTheirSavedId(3, all red with the capture reverted);test_shot_transfer.pynewtest_the_scenes_member_ids_stay_home(red: every member's id crossed, the export scope ignored).
- Test(s):
- 2026-10-11 -- An export's own key edits are no shot's (
core_utils/engines/shots/shot_model.pynewShotStore.transient_edit,ShotStore.in_transient_edit). With the Shot Sequencer open, an export's bake, snap and tie (put back after the write) were banked as the user's keying, and the keying refresh added an object keyed in the active shot's frames to its members. Measured on a Blender Unity send and a Maya Scene Export: a non-member joined the active shot.transient_editis the bracket an export or a bridge send holds, re-entrant and released on a raise. Both DCC listeners bank nothing inside it.- Test(s):
test_shots_core.pynewTestTransientEdit(2); the DCC halves are pinned in mayatk and blendertk.
- Test(s):
- 2026-10-11 --
ShotSequencer.paste_pairs: which copied key row each pasted row receives (core_utils/engines/shots/shot_sequencer.py). Lifted from mayatk's Shot Sequencer key menu so blendertk's Ctrl+V pastes the same way. A lone row goes on every destination; otherwise each row lands on its attribute's row, on the same object when it is a destination, else cycling through the copied objects in order. An attribute not copied gets nothing. The name matching is injected (leaf, identity by default). New public method: minor bump.- Test(s):
test_shot_sequencer.pynewTestPastePairs(5).
- Test(s):
- 2026-10-11 -- The shot store follows the DCC's own undo and redo, and a restore point is discarded at most once per push (
core_utils/engines/shots/shot_model.pynewShotStore.follow_native_step,following_native_step,native_undo_sync_held,add_native_step_listener/remove_native_step_listener, eventNativeStepFollowed, hooks_native_step_crossed/_install_native_undo_sync/_remove_native_undo_sync(run byactive,set_active,clear_active);discard_boundary_snapshotnow returns whether it dropped a point). A restore point (claims, Shots-window bounds, a Build's markers) was put back on a native undo only by the Shot Sequencer controller's listener, and uitk keeps a closed panel's controller, so the real trigger was "no Sequencer opened this session". Measured in a fresh mayapy: an Align with claims [3, 10], undone with no controller, left claims [9]; a Shots-window resize undone kept its bound; 2 of 6 probe scenarios restored, 6 of 6 after the fix. The ledger lives on the store, so the store follows the undo: the DCC store supplies the pairing test and the subscription, and a panel only redraws onNativeStepFollowed(maintainer 2026-10-10).discard_boundary_snapshotdrops a point once per push. mayatk'sscene_editnow drops a no-op edit's own point, and the 29 callers that then discard "their" no-op point were taking the restore point of the edit before it instead. Public behaviour change: needs a minor bump.- Test(s):
test_shots_core.pynewTestFollowNativeStep(8);TestDiscardRestoresTheRedoBranch+3 (test_a_second_discard_drops_nothing,test_no_discard_after_the_point_was_applied,test_a_capped_push_is_still_discardable_once), all 3 red with the old discard.
- Test(s):
- 2026-10-11 -- The Shot Manifest reads past the members the shot system added on its own (
core_utils/engines/shots/shot_model.pynewShotStore.SYSTEM_MEMBERS,add_system_members,system_members;update_shotdrops the tag of a member that leaves;manifest/manifest_engine.pyShotManifest.edited_by_hand). Any change to a built shot's members counted as a hand edit. Opening a built step with no objects, or only missing ones, in the Sequencer (which fills such a shot with what is keyed in it), or keying an object in the active shot, made the shot "edited". Every later Build then kept it, doc changes and all, unless Rebuild Edited Shots was on. Maintainer 2026-10-11: tag the system's additions so the check skips them, and a hand's add or remove still counts. A member a hand removes takes its tag along, so adding it back counts as the hand's.add_system_memberskeeps every member a shot has; a doc name the scene lacks stays, inert, where the fill used to replace it.- Test(s):
test_shots_manifest_core.pyTestEditedShotsAreKept+5 (the system fill and the missing-object fill red with the old check; a hand's add, removal and remove-then-add pinned);test_shots_core.pynewTestSystemMembers(4).
- Test(s):
- 2026-10-11 -- Shot sequencer: a Shift retime pins its flush seams before it moves anything (
core_utils/engines/shots/shot_sequencer.pyresize_shot, new_pin_retimed_seams). On each edge the retime moves with a FLUSH neighbour, every content curve is pinned on the seam (_pin_bound_poses, a curve crossing it keyless included), claimed as the earlier shot's end, so the DCC retime has a key to hold the neighbour's half on (mayatk/blendertkscale_shot_keys). Keyless, a grow re-timed the next shot's opening: 0.08 off. SOL numbers: mayatk's entry.- Test(s):
test_shot_sequencer.pyTestContentLeavingAShotKeepsItsNeighboursPosesnewtest_a_retime_pins_its_flush_seams_first(red with the pin off).
- Test(s):
- 2026-10-11 -- A new shot store starts with a gap of 10; a saved scene keeps its own (
core_utils/engines/shots/shot_model.pynewShotStore.DEFAULT_GAP,ShotStore.LEGACY_GAP). The maintainer's call (2026-10-11): the production spacing, so a new shot does not share its seam frame with its neighbour. A scene's saved gap loads as saved, 0 included, and a payload written before the gap was saved still reads as 0. Split Here is unchanged (split_shot(gap=0)). A new store's Move to Shot separation (sequence_separation), New Shot and reorder spacing follow the default. SOL_REPLACE_ASSEMBLY (saved 10) loads at 10; an empty scene's store starts at 10.- Test(s):
test_shots_core.pyTestShotStoreSerialisationnewtest_a_new_store_defaults_to_the_production_gap(red before: noDEFAULT_GAP),test_a_saved_scene_keeps_its_gap;test_shot_sequencer.pytest_insert_after,test_insert_appends_by_default,test_move_to_position,test_new_shot_takes_the_next_name_and_the_initial_length,test_sequence_separation_is_one_frame_past_the_gapupdated to the default.
- Test(s):
- 2026-10-11 -- Assess reports what a Build would rewrite on a built shot that no object status shows (
core_utils/engines/shots/manifest/manifest_model.pynewStepStatus.updates,StepStatus.UPDATES,StepStatus.needs_buildcounts them;manifest_engine.pynew_ShotManifestInternal._updates,_recorded_doc-- shared with_compute_plan). A step renamed by Local Edits, a new description, an object or audio clip the last build recorded and the step no longer lists. Before, an Assess after a built step's rename turned Build off, and the rename was never built. A rename the store refuses (another shot holds the name) is not pending; a kept or locked shot has nothing pending.- Test(s):
test_shots_manifest_core.pynewTestAssessSeesWhatBuildRewrites(6, all red before; 219/219).
- Test(s):
- 2026-10-11 -- Shot sequencer: every op that closes a seam lands it flush where the two poses agree and keeps it as a straight cut (1 frame apart, the gap hold stepping it) where they differ -- a delete, the scoped gap collapse, a respace to 0, a band move, a slide, a Ctrl edge and Move to Shot's landing, head and tail -- never refused and never re-timing a shot; Move to Shot copies a seam sample instead of carrying it; a released gap hold that other shots play stays (
core_utils/engines/shots/shot_plan.pyShotPlanner.plan_respace(seam_gaps=), newShotPlanner.closing_seams;shot_sequencer.pynew_close_flush_seams,_closing_slide_start,_closing_edges,_landing_floor,_head_landing_floor,_hold_landing_neighbour,_seam_samples_of,_hold_runs_through_shots, new hook_poses_differ;respace,delete_shot,apply_gap,slide_shot,set_shot_start,set_shot_edge,_send_sequences_to_shot,_hold_neighbour_poses(flush=, crossing=)(wascloses=), hook_pin_bound_poses(crossing=)). The maintainer's rule (2026-10-11), for every op that closes a seam ("ALL of them"). Poses are keyed or held: the value each curve plays at the earlier shot's end and the later shot's start, read before anything moves (the DCC's_poses_differ). The planner names the seams an edit closes (closing_seams: flush once applied, apart now), and each one whose poses differ gets a one-frame floor (the store's snap) in the op's own layout -- a respace's plan, a ripple's, a slide's, an edge's bounds -- so the rule lives in one place and a respace no longer raisesShotBoundaryConflict. A seam left apart closes flush on a later op only once its poses agree; one info line names the seams left apart. A Ctrl edge pushed past the neighbour's bound takes that shot's frames, one pose each: no closing. Move to Shot's tail landing keeps the next shot a frame away where the landed run closes on a different pose, and pins that shot's opening slope; its head landing still gives the block the destination's start (maintainer 2026-09-23), a frame after the shot before where that shot closes on a different pose, so it keeps its closing pose (it ended on the block's before: S0 7 -> 0), and a head block that becomes its curve's last key before the next shot pins that shot's opening pose (it played the block's: SOL "D05"SOCKET_WRENCH1.7 off over 61 frames, on the old path too); a run whose first or last key is a flush seam's sample leaves a copy for the neighbour (carried, the curve's first key left and every earlier shot played its new first key: SOLSOLENOID4.85 off over 1734 frames). A gap hold written before its stop pin existed runs on through later shots; released when its gap closed, it re-timed them all (SOL respace 10 -> 0:LCKR_DOOR_LOC_rotateYoff in 15 shots, up to 1.22 in C12, and the layout settled only on a second respace). It now stays, its claim dropped. SOL_REPLACE_ASSEMBLY, read-only (sha unchanged), the acceptance now also reading the frame two shots share where it changes hands: a respace 10 -> 0 plays every shot on (0 of 381,600 samples off) and settles in one pass, 4 seams kept as straight cuts (A01_1 | A01_2, A07 | B06_1, B10_2 | B11, C12 | D05: the 12 curve pairs whose poses differ). Every band move, slide and Ctrl edge dropped flush on those seams keeps a straight cut with both shots playing on (A's last frame had played B's pose, up to 71.5 off). Every shot op: gap 0 80/83 pass, gap 10 65/67 (74 and 61 before the closing rule); the three left are the keyless-seam re-time (BACKLOG).- Test(s):
test_shots_core.pynewTestAClosingSeamStaysOneFrameApartWhereItsPosesDiffer(4);test_shot_sequencer.pynewTestAClosingSeamStaysOneFrameApartWhereItsPosesDiffer(14: respace, delete, collapse, band move, slide and Ctrl edge each a frame short and flush, a push, the log line, no read without a closing) andTestMoveToShotCopiesASharedSample(6, two head landings); inTestContentLeavingAShotKeepsItsNeighboursPoses,test_a_delete_pins_its_flush_neighbours_tooandtest_a_next_shot_one_gap_away_is_pinned_whatever_closesreplace the two that asserted a flush seam is not pinned, and two pin windows are updated. Every test that changes behaviour failed before its change; the flush-where-they-agree ones are guards and pass either way.
- Test(s):
- 2026-10-11 -- A sheet reads in whichever step-ID layout it is written in (
core_utils/engines/shots/manifest/manifest_model.pynewColumnMap.layouts,_ManifestModelInternal._parse_layout;mapping/_mapping.pyMapping.apply_options(a choice'stry);mapping/_spec.py_try_errors;mapping/default.jsonStep IDsauto, the new default). Reported: Assess "completely broken" on SOL_REPLACE_ASSEMBLY. Its sheet writesA01/IN_01; the default template's Step IDs option defaulted toclassic(A01.)), kept per user, so the sheet read as 0 steps -- and the user's other production sheets areclassic, so whichever was read last broke the other.ColumnMap.layoutslists alternative layouts; the rows are read once per layout and the one reading the most steps is kept (the first on a tie), only its warnings logged. A template choice maytryother choices (theircolumnspatches become the layouts); the default template's Step IDs now default to Auto (try: [classic, numbered]). On the real sheets: a numbered production sheet 0 -> 57 steps; two classic ones read as before (23, 85); a third reads its 10 branch steps (A04.6.)), whichclassicfolded into the step before. A header with no step row read now logs why, with the step cells it could not read.- Test(s):
test_shots_manifest_core.pynewTestColumnMapLayouts(4),TestSequenceDocMappingtest_auto_reads_it_as_numbered_and_is_the_default,test_auto_keeps_a_classic_sheet_classic,TestTemplateOptionstest_a_try_choice_tries_the_named_choices_column_layouts,test_a_bad_try_is_a_schema_error(5 red before the fix).
- Test(s):
- 2026-10-11 -- Shot sequencer: five more ways an op changed what another shot plays, found by a SOL_REPLACE_ASSEMBLY acceptance of every shot op at gap 0 and gap 10 (
core_utils/engines/shots/shot_plan.pyShotPlanner._plan_sequence, new_closed_touch,boundary_splits(pending=),keyless_seam(dropsprev_start);shot_sequencer.pydelete_shot,_send_sequences_to_shot, new_animator_run,_hold_neighbour_poses, new hook_pin_bound_poses). The rule is the maintainer's (2026-10-10): an op on one shot never changes what another shot plays inside its own span. (1) The planner read two windows that only touch at ends both hold closed as disjoint, so deleting an empty shot moved one shot's closing sample onto the next shot's old start, and that shot carried it on: 400 frames in all, a door pose at frame 0. (2) Move to Shot read a run by its drawn extent, which ended on the next shot's claimed start pin, so a block from before the destination was appended behind it; the curve's last pose became the block's and the shot after played it (KEYS78 off over all 81 frames of A02). A run is now the animator's keys inside it. (3) Content leaving a shot (a delete, or Move to Shot out of it) first pins the pose each neighbour plays on its facing bound, held and extrapolated poses included, claimedstays. At gap 10 deleting A01_2 had left A02 playingKEYS78 off, and deleting C01 left C02'sSOLENOID7.2 off over 256 frames. A bound the next shot then lands flush on is not pinned (BACKLOG decision). (4)keyless_seamasked for a key in the preceding shot; a shot that keys none of a segment still plays it. A door ramp keyed at 1061 and 3230 runs through 30 flush shots, and a band move at 2116 re-timed it in each (up to 0.003). (5)boundary_splits(pending=)now reports a seam the enclosing edit opens later, from the edit's first plan: a slide moves the shots after the pivot first, which already re-timed a segment crossing the pivot's seam. SOL, read-only (sha unchanged), 150 ops x ~330k-380k samples each, every undo exact: gap 0 74/83 pass (30 before), gap 10 61/67 (35 before), no op worse. What remains is in BACKLOG (the gap-0 delete, a collapse onto a held tail, a few keyless-seam ops, one round trip).- Test(s):
test_shots_core.pynewTestAClosedEndLandingOnAClosedStartMovesOnce(2),TestBoundarySplits+1,TestKeylessSeam(4, one inverted: a seam the preceding shot only interpolates is pinned);test_shot_sequencer.pyTestHookContract+1 (a run ending on the next shot's pin lands in front), newTestContentLeavingAShotKeepsItsNeighboursPoses(6),TestPendingBounds2 updated (the seam is reported from the first plan). Each new test failed before its fix (the planner order [3, 2, 4] and the seam key at 50.0; the block appended at 91; no pose pin; a TypeError on the old signature;boundary_splitsreturning []).
- Test(s):
- 2026-10-11 -- Shot sequencer: opening a flush seam that has no key on it no longer re-times the shot before it, a respace keeps a held tail holding, and the gap-hold pin pass keeps no memory (
core_utils/engines/shots/shot_plan.pyShotPlanner.boundary_splits(pending=), newShotPlanner.keyless_seam;shot_sequencer.pynewShotSequencer.pending_bounds,_opened_seams,_slid_bounds,respace,add_shot_space,expand_shot,resize_shot,set_shot_duration,move_sequences_to_shot;shot_ledger.pyShotEditLedger.record_key(stays=), newkey_stays). A curve that moved through a flush seam with no key on it had nothing to split, so every op that pulled the following shot away re-timed the segment both shots shared. Measured 2026-10-07: keys at 0/40/100 under A [0, 50] and B [50, 100], with B moved 10 frames, played A 1.21 off and B 1.10. On SOL_REPLACE_ASSEMBLY one curve of 16 was off over 4096 samples, and 3 of 39 over 3393. The rule is the maintainer's (2026-10-10): an op on one shot never changes what another shot plays inside its own span. The DCCs'_reconcile_boundaries, the chokepoint every whole-shot move passes, now pin the earlier shot's own pose on a seam the move leaves open (keyless_seam). The pin is claimed as that shot's end and markedstays, so it is disowned in place, never followed, when that bound later moves. The pin is not placed when a seam is created, because the ops that create one are an open set (a Split,define_shot, Build over motion, an animator's keys, a deleted seam key, older scenes).boundary_splits(pending=)takes the caller's final bounds. An edit that ripples its neighbours before it writes its own bound is then not reported as a split, so no key is pinned in the way of what lands there. Without it Move to Shot's expansion pinned a key its landing then fought. The edits that declare their final bounds arepending_boundsblocks: Move to Shot's expansion and head room, a slide, and a retime. The trailingadd_shot_spaceandexpand_shotnow ripple before they write the bound, asresize_shot_boundsdoes. Writing it first made a flush neighbour read as no neighbour, so neither a keyed nor a keyless seam was split. Pinning a held tail's end was considered for the respace and rejected, because padded tails drift toward the next shot's first pose. Insteadrespaceholds every gap before it pins anything. A shot tail after its last key then holds through a respace as it does through every other op; before, the respace pinned the curve played on (A's tail 1.96 off its held playback in Blender). The New Shot cost (190 pin writes per edit on a production assembly, inserting nothing) was briefly cut with a memo of settled seams, which went stale on any edit but the sequencer's own: an undo left a seam unpinned and the memo still called it settled. The pass stays stateless, and the DCC hooks decline cheaply instead.- Test(s):
test_shot_sequencer.pynewTestGapHoldPinsKeepNoMemory(2),TestRespaceHoldsTailsFirst(1),TestPendingBounds(4);test_shots_core.pyTestBoundarySplits+2 (pending=), newTestKeylessSeam(4), newTestEditLedgerStays(2). Before the fix 13 of the 19 new and touched tests failed; the two no-memory tests fail against the memo variant.
- Test(s):
- 2026-10-11 -- Shot Manifest: a behavior type can be marked on the timeline instead of keyed (
core_utils/engines/shots/manifest/manifest_engine.pyShotManifest(marked_behaviors=),place_markers,planned_markers,unkeyed_behaviors;manifest/behaviors/_behaviors.pyBehaviors.key_window,anchored,entry_anchors,is_audio_entry,label;shot_model.pyShotStore.set_markers,marker_owner,MarkersChanged;shot_transfer.pymerge). Requested: "a third option (maybe using a combobox) of adding a marker in the sequencer instead of keying or simply ignoring all together".synckeys nothing for a marked behavior: it places one timeline marker per shot, behavior and frame, where the keys would start (Behaviors.key_window-- an effect at the effect writer's ownEffectRecipe.window, each object anchored as a build anchors it), naming the objects. A rebuild takes out its markers on the shots it reaches (all but a turned-off type's) and places them again; a hand's markers and a kept shot's are never touched. Assess judges a marked behavior by its marker, so a missing or moved one enables Build. A Build's markers carrydata={"owner": "shot_manifest", "shot_id", "behavior", "objects"}: a restore point carries every tool-owned marker (undoing a Build takes its markers out, inside the restore's one batch; a hand's are never in one), andShotTransfer.mergerenumbers the shot a marker names as it renumbers a claim's owner.Behaviors.anchoredis now the one anchor precedence (mayatk's and blendertk's appliers and verifiers route their four copies through it), andentry_anchors/is_audio_entrythe build loop's placement and audio test, shared with the markers (the DCCs'apply_to_shotscopies route through them).- Test(s):
test_shots_manifest_core.pynewTestMarkedBehaviors(7),TestBehaviors+3 (key_window,anchored,entry_anchors);test_shots_core.pynewTestToolMarkers(4: owner, change-only event, undo/redo keeps a hand's marker, a marker-only edit is an edit);test_shot_transfer.pytest_merge_renumbers_the_shot_a_tools_marker_names;surface_snapshot.jsonpinsMarkersChanged. 531/531 across the shots, transfer and export files.
- Test(s):
- 2026-10-11 --
MapOptimizer.stage_mapsreuses an encode only for the same source file under the same settings, and a repeat export encodes nothing (core_utils/engines/textures/map_optimizer.py). Reuse wasoptimize_map(check_existing=True): a staged file newer than its source shipped, whatever wrote it. Measured 2026-10-10 on the USD route (durabletextures/staging) and in pure pythontk: exported at a 1024 ceiling, then unclamped -> 3/3 maps shipped at 1024 from 4096 sources; a source repainted with an older mtime -> the old pixels shipped; a second scene's same-named maps into the same folder -> 2/2 shipped the first scene's pixels. And an FBX or GLB run (throwaway staging) never reused anything: every map was encoded again on every export, including the trial re-encode of a PNG that saves under 5% and ships as it is. Each staged encode is now cached in aTempArtifacts("map_optimizer_cache")slot keyed onCachedArtifact.keyover the source (path, mtime, size), the template, the container,assess's prediction of the exact request (read from the header; so a size ceiling enters as its effect on each map, and changing it re-encodes only the maps it reaches -- the raw ceiling in the key would re-encode them all),MapOptimizer.CACHE_VERSIONand the package and Pillow versions -- and every other option as given (a KTX2 RDO dictionary changes the bytes and not the prediction), only the size rules entering as their effect. An encode lands in scratch and is promoted by one rename under its real file name (the manifest classifies a map by its name); a "kept" verdict is a marker in the slot, so the trial runs once; a throwaway staging dir gets a hard link to the slot, a durable one a copy (a deliverable owns its bytes). Slots age out with the store's 7-day sweep, a hit touching its slot so a map in use stays cached; write-back is never cached. The first fix proposed (a durable dir keyed on the export folder) was rejected: it spreads the mtime reuse to FBX, and its key is exactly the second-scene case. Additive: the report'scachedcount and each record'scached. Retired:optimize_map(check_existing=...)warns throughDeprecation(remove_in=0.15.0; still honoured this release) -- its mtime-only reuse is the defect, and no caller remains. Production (a 486-mesh assembly, 51 maps, Optimize Textures for an FBX, scene read-only, sha256 and mtime unchanged): run 1 encoded 46 maps in 47.6 s (6 shipped, 40 kept); run 2 encoded 0 in 0.08 s, every staged map's pixels identical; run 3 at a 2048 ceiling encoded exactly the 43 maps over it in 25.9 s, the 3 under it from the cache; the cache held 98 MB (49 encodes, 40 kept markers).- Test(s):
test_map_optimizer.pyTestStageMaps+7:test_a_staged_copy_from_a_looser_pass_is_not_reusedandtest_a_staged_copy_of_another_source_is_never_reused(red before: 128 px and the first scene's pixels shipped),test_a_second_temp_staged_pass_encodes_nothing(encodes counted through a wrapper onoptimize_map; the slots' mtimes unchanged),test_a_throwaway_stage_links_the_cache_and_a_durable_one_copies_it,test_a_kept_recompress_is_not_retried,test_a_changed_clamp_re_encodes_exactly_the_affected_maps,test_optimize_map_check_existing_is_retired; each cache test red under its own mutation (no cache hits: 5 fail; the raw clamp in the key: 1; never link: 1). Review 2026-10-12:test_an_option_the_prediction_cannot_see_enters_the_key(red: one key for two dictionaries),test_a_cache_hit_keeps_its_slot_from_the_age_sweep(red with the touch off). Twins: mayatk and blendertktest_scene_exporter.py(a second FBX export encodes nothing).
- Test(s):
- 2026-10-11 --
FbxMedia.merge_takes(keep=...): the takes a layout stashes ride the merged file whole, and a layer named after the kept take takes the merged name (file_utils/mesh_convert/fbx_media.py). An importer that makes an action per (take, object) -- Blender's -- parks a clip library's alternatives from ONE import only when the file holds both what plays and what is parked: a second import of the same objects could only duplicate them.keepleaves the named takes untouched, each its own stack beside the merged one; a kept take that is also merged, is named the merged name, or shares a curve, curve node or layer with a merged take raisesValueError(the merge would rewrite it). Blender names each layer after its take, and its importer names an action<object>|<take>|<layer>once the two differ, so a merged Shots Only file arrived ascube|Take 001|<first shot>: a layer bearing the kept take's name is renamed with it. Additive; the default behaviour is unchanged.- Test(s):
test_fbx_media.pyMergeTakesTestCase+3 (test_kept_takes_stay_whole_beside_the_merge,test_keep_refuses_a_merged_take_its_name_or_an_unknown_take,test_a_layer_named_after_its_take_takes_the_merged_name; thebuild_curve_fbxfixture names its base layer throughbase_layer); each red under its own mutation (keep dropped, validation off, no layer rename). Consumer: blendertkFbxUtils.import_fbx.
- Test(s):
- 2026-10-11 --
FbxFile.take_layout+FbxMedia.merge_takes: a multi-take FBX imports ALL its animation, not one take's (file_utils/mesh_convert/fbx_file.py,fbx_media.py). No DCC merges takes on import -- Maya plays the one takeFBXImportSetTakenames, Blender gives each object the first stack's action -- so a Shots Only file imported one shot and a Blender->Maya push kept only the last.take_layout(tolerance=1e-4)sorts the takes by one rule: the current take (else the first keyed one) seeds the live set; any other take that disagrees with it on a shared channel's overlap is stashed WHOLE (a clip library's alternatives); one that agrees and keys frames no live take spans is live (Shots Only windows, gap-0 seams, per-object stacks, a take adding a channel); one that adds nothing is redundant (Maya's or Blender's whole-timeline take beside its slices, wherever it sits). It returns{"takes": [{"name", "index", "start", "end"}], "current", "kind", "live", "redundant", "stash", "conflicts"}-- index 1-based in file order (Maya'sFBXImport -t), start/end in seconds -- withkindnone / single / covered / merged / conflicted.merge_takes(src, dst=None, *, takes=None, name="Take 001")rewrites the live takes as ONE take: every key at its stored time with its stored value, flags and slopes, a seam key once, a channel / curve node / layer only a later take has re-wired onto the kept take, the kept stack's span andTakesentry widened and made current, every other stack dropped; takes that disagree raiseValueErrornaming both takes, the channel and the frame. Takes are judged by each curve's own interpolation, and Maya writes its spline, clamped and auto keys with ZERO slopes, recomputing them on import (measured, Maya 2025 / FBX 2020.3.6), so the evaluator computes them as Maya does -- clamped flat within 0.05 of a neighbour in Maya's internal unit (radians on a rotation), auto capped at three times either secant -- and reads weighted tangents as 16-bit fractions of 9999; against Maya's own evaluation of a Maya-written file every tangent type lands within 1e-7. Why a file-level merge: Maya has no take merge (38 FBXImport*/FBXGet* commands checked), sequential merge imports REPLACE a curve, a per-take namespace import pasted back lost 63 of 1735 channels on production, and a writer-side whole-timeline take breaks Unity's Shots Only contract. On a production 18-shot Shots Only assembly (343 MB, 1735 channels, source sha256 unchanged):merged, all 18 live, 0 stashed in 1.7 s (after a 5.4 s load withdecode_arrays=FbxFile.CURVE_ARRAYS; a plain load re-reads its curves); merged in 17.6 s, 7,550,720 keys, 0 mismatches against every take; a fresh Maya imports the merged file in 12.4 s with every key of takes 1, 9 and 18 at its time -- rotations only re-unrolled by the importer across the longer curve (same pose, 1 - |q.q| below 1e-14).drop_takesnow runs on the shared_drop_stacks(unchanged behaviour).- Test(s):
test_export_verify.pynewTestFbxCurveEvaluation(6, incl.test_a_maya_written_file_evaluates_as_maya_reads_itagainst the newtest_assets/fbx/maya_tangents.fbx+ Maya's samples) andTestFbxTakeLayout(11);test_fbx_media.pynewMergeTakesTestCase(9) and thebuild_curve_fbxfixture (written byFbxWriter). All red before; each of six mutations (stored zero slopes, an absolute tolerance, a seam kept twice, no redundancy, no re-wiring, no conflict check) turns its tests red. Review 2026-10-12:test_a_gap_before_a_one_key_seam_meets_the_key_that_is_kept(red: the run-wise join gave the key before a gap the in-slope of a one-key take's key the seam after it drops; such a seam is now joined key by key, and a 3000-case differential against the key-by-key union reads 0 mismatches),test_a_corrupt_curve_array_is_a_value_error(red:zlib.errorescaped the merge). A weighted span's time is solved byKeyCurve.bezier_parameter.
- Test(s):
- 2026-10-11 --
KeyCurve/CurveKey: one animation curve, host-free (math_utils/key_curve.py). Keys carry their tangent handles as vectors in frame / value units; each span isbezier,linear,steporstepnext(plus easedsamples), and the curve readspre_infinity/post_infinityasconstant,linear,cycle,cycle_offsetoroscillate.evaluate,value_bounds(cubic extrema included),normalization,segments()(the span dicts the uitk sequencer draws),to_preview()(its track rows'curve_preview) and copy-returning edits (with_keys_movednever crosses a neighbour,with_handleswings an unbroken partner as each host does,with_infinity). An unweighted curve keeps only each handle's slope and pins the control point a third of the span out; a weighted handle that passes its span is pulled back along itself (Blender's correction). The graph editor's model, and the one reading both DCC sequencers' curve previews now share.- Review 2026-10-12:
value_boundspast the keys takes in every repeat a cycle, cycle-with-offset or oscillate infinity plays there (it read only the window's two ends, so the graph editor framed a cycled curve as a flat band); linear infinity off a degenerate end (two keys on one frame, a vertical handle) continues flat, as Blender's does, where it divided by zero or ran at 1e6 per frame; a drag against a key already nearer thanTIME_EPSstops instead of reversing. NewKeyCurve.bezier_parameter: the one Bezier time solve, shared withFbxFile's weighted tangents. - Test(s): new
test_key_curve.py(24: an unweighted span equalsMathUtils.evaluate_hermite, every infinity mode, bounds, preview windowing, edits; +3 from the review, each red before its fix);surface_snapshot.jsonpins both names. Host proof: mayatktest_anim_utils.pyTestGetKeyCurves, blendertktest_mat_anim_utils.py(get_key_curvesvs the host's own evaluation).
- Review 2026-10-12: