Tier 1 · Applications
Animation

AnimHub

dcc/maya/animation/animhub/main_window.pyCFAnimHubWindow (WINDOW_OBJECT_NAME = "CF_AnimHubWindow"), launched from Animation > AnimHub. The clip/take manager and FBX export pipeline — the largest application in the suite, and a clean-room reimplementation of an animation-clip workflow, informed by years of real production use of a previous, unrelated pipeline rather than a port of its code.

Its Maya-menu/QuickLaunch icon sits next to its launcher module (see Menu discovery) — the in-app window itself doesn't set its own window icon.
Design goals
1
Problem

Animators working across a large scene need to manage many independent playback ranges on one shared timeline - preview, retime, and export a subset without losing track of what's finished, what's still in progress, and what shouldn't ship. Ad hoc timeline bookmarks and manual FBX exports don't scale past a handful of ranges, and don't carry export metadata (which character, which path, which animation layers) with them.

2
Architectural decision

Model a "clip" as a first-class, named window into the shared timeline - not a separate file, not a separate layer - carrying its own status, per-character export targets, animation-layer scoping, and preview data as real attributes on a real scene node. Two-way, deliberately non-live sync bridges to Maya's native Time Editor, Bookmark Editor, and Game Exporter mean the tool interoperates with Maya's own systems rather than replacing them outright.

3
Production benefit

A batch of clips can be previewed, retimed, reorganized into groups, and exported to a configurable project convention without ever leaving one window - and because the AI Companion is bound with a curated API scoped to exactly this data model, natural-language requests ("de-overlap these clips with a 2-frame gap and bake the result") become constrained, reviewable scripts instead of manual, error-prone timeline surgery.

Clip Node range · status · export slots Time Editor Bookmark Editor Game Exporter AI Companion via ClipsAPI Export Pipeline → FBX

Sync with Time Editor, the Bookmark Editor, and Game Exporter is deliberately one-shot, not live - each direction is an explicit, user-triggered action, never a background link that could silently drift out of sync.

Core concept: what a clip is

A clip is a named window into Maya's single shared timelinestart/end mark a frame range on the one timeline every clip shares. It is not a separate animation layer, and not a separate file per clip. This distinction matters everywhere in the app: pushing a clip to the Time Editor, baking anim layers, and exporting FBX all operate on "this range of the one timeline," not on any kind of isolated take container.

Clip data model

A clip lives on a native Maya network node carrying a set of CF_Clip* attributes (schema.py), applied generically by a declarative AttrSpec table (same pattern as character/schema.py) — adding a field means editing that table, not hand-writing another cmds.addAttr call.

AttributeKindPurpose
CF_ClipName / CF_ClipStart / CF_ClipEndstring / long / longName and frame range. Real Maya long attributes, not string-encoded ints.
CF_ClipStatusenumReady InProgress Ignore
CF_ClipCheckedboolThe export-batch checkbox.
CF_ClipColordouble3Row color swatch.
CF_ClipGroupsstring (CSV)Membership only — see Groups for why this alone doesn't make a group real.
CF_ClipHubmessageConnection to the singleton hub node (CF_AnimClipsHub), which also holds CF_ClipGroupOrder — the group registration list.
CF_ClipBookmarkstringThe paired timeSliderBookmark node's name — see Bookmark sync for why this is a plain string, not a connection.
CF_ClipExportChars (compound, multi){char_uuid, enabled, path}Per-character export slots — one clip can target several characters. char_uuid matches CFCharacter.uuid, not a node name, so a slot survives a character rename.
CF_ClipEnableLayers / CF_ClipDisableLayersstring (CSV)Anim layer names to force on/off at export — see Anim layer scoping.
CF_ClipExportCurveData / CF_ClipExportCurveDataHeadboolSchema/UI fields, not currently acted on by the export pipeline — a previous pipeline's version of this feature was specific to a rig system this project has no equivalent of, so this stays a deliberate, documented gap. Gap
CF_ClipLoopboolStamped onto the exported FBX as a keyed custom attribute (CF_Loop) — see Export pipeline.
CF_ClipOverridePathstringClip-level export path override — wins over any per-character-slot path.
Groups are two-layer, on purpose: a clip's CF_ClipGroups CSV is just membership. A group name only becomes real (orderable, eventually a UI tab) once it's separately registered in the hub's CF_ClipGroupOrder. Tagging a clip with a never-registered group name does nothing visible by itself — see Groups.

Start here Main window tour

What you're looking at the first time you open CF AnimHub — menu bar down to the bottom bar, top to bottom.

Menu bar

File (Import/Export Clips), Clips (Import from Bookmark Manager, batch rename, groups manager, a Clip Preview submenu — Enable Clip Preview / Generate Missing Previews / Delete All Previews, grouped together), Characters (character hierarchy view, panel toggle), Tools (Time Editor sync, Game Exporter sync, Clip Previewer, Clip Shuttle, background export, and Ask AI), Help.

Character panel

Sits under the menu bar, above the toolbar; hidden by default, toggled from Characters > Display Character Panel.

🔍

FILTER VIEW bar

Search box, a sort/filter combo (by start frame, by name, or filtered to one status/checked-state), and three range buttons (Checked / Group / All) that set Maya's timeline range to span whichever clip subset.

Group tabs

One tab per registered group plus "All" and a "+" to create a new one; switching tabs filters the clip list, it does not rebuild per-tab widgets.

ACTIONS bar

Check All / Expand All toggles, Prev/Current/Next navigation (native reimplementation against the sorted clip list, not Maya's own bookmark-plugin navigation), and + Add Clip.

Clip rows

One per clip, in Optimized, Compact, or Preview style (Settings > AnimHub > Interface) — see Clip row anatomy for the full breakdown.

Bottom bar

Export FBX.

Corner toolbar

Five icon-only buttons sit in the menu bar's own top-right corner — left to right: Character Panel toggle, Clip Style cycle, Preview toggle, Clip Previewer launch, and Ask AI.

AnimHub's corner toolbar: five icon buttons

Character Panel

Shows/hides the character hierarchy panel under the menu bar - same toggle as Characters > Display Character Panel.

Clip Style

Cycles every row between Optimized, Compact, and Preview - see Clip row anatomy below.

Preview Toggle

Enables/disables the ambient hover-preview popup for the whole window - see Hover preview popup.

Clip Previewer

Opens the standalone gallery window - see Clip Previewer (gallery window).

Ask AI

Opens the shared AI Companion, bound with AnimHub's own curated API - see AI Companion integration.

Clip row anatomy

clip_row_widget.py's ClipRowWidget is the single most-interacted-with piece of UI in the app — one instance per clip, rebuilt from scratch on every filter/sort/refresh (not diffed/reused). It's one parametrized class covering all three row styles, not three separate widget files — AnimHub has no legacy-file-pairing constraint forcing a split the way some ported UIs do.

ClipRowWidget(clip, style="optimized", initial_expanded=False, parent=None)

Signals: changed() (any field write), filterRelevantChanged() (only for edits that could move the row in/out of the current filter/sort — checked, name, status; deliberately not fired for start/end, since a spin box fires per-keystroke while focused and rebuilding the row list mid-edit would yank focus away), expandedChanged(clip, bool), duplicateRequested(clip), deleteRequested(clip), exportRequested(clip) (export just this one clip, regardless of its checked state).

The three styles

Optimized row style: single-line header
Optimized

Maximum density - one clip per line, nothing but the fields you need to scrub and check off a long list quickly.

Compact row style: two-line header with status pill
Compact

Trades density for readability - a full-width name field for long clip names, and the status pill visible without expanding the row.

Preview row style: two-line header with a persistent thumbnail
Preview

Swaps the status pill for a persistent thumbnail - built for visually scanning a list once clips actually have captured preview frames.

Expanded body (same for all three styles, toggled by the chevron): three sections, each its own QGroupBox with an explicit dark background (a bare QGroupBox is transparent, and the row's own status-tinted gradient would otherwise bleed through it):

Expanded row body: Characters, Animation Layers, and Export Options sections
Expanded

All three sections at once, plus the actions row - Ready status pill, Capture Preview / Duplicate / Delete.

Current-clip highlighting

Driven externally, not by anything inside the row itself: the main window registers a timeChanged scriptJob and, on every fire, calls widget.set_current(widget.clip.start <= cur <= widget.clip.end) on every visible row. set_current() just flips a flag and re-tints the card — there's no scriptJob or timer living inside ClipRowWidget itself. "Current" overrides the status-color tint outright (not blended), so it's unmistakable rather than competing with the status signal — matching the same precedence the interaction pattern this was adapted from uses.

Ctrl-drag scrub / Alt-click play-once

A custom event filter installed on the row and every direct header child widget — explicitly excluding anything inside the expanded body, since the body's sections rebuild their children dynamically (a filter installed once at construction would go stale) while header widgets never change identity after construction.

Hover preview popup

For Optimized/Compact rows (Preview style has its own always-on thumbnail instead): once the corner toolbar's Preview Toggle is enabled, hovering any row starts a delay timer (Settings > AnimHub > Interface's preview delay, default 1200ms), then shows a small floating ClipPreviewPopup (clip_preview_popup.py) near the cursor, animating back through the clip's own captured preview frames right where the cursor is — a quick way to check what a clip actually looks like without scrubbing the timeline or leaving the row list.

Preview Toggle enabled in the corner toolbar, with the hover popup playing back a clip's cached animation over the row list
Hover playback, triggered

Preview Toggle (highlighted, corner toolbar) is on, so hovering as_crea_chicken_loco_fidget_01 pops the cached preview frames right over the row — no scrubbing or opening a separate window needed.

Playback speed itself is also a live, user-adjustable setting rather than a hardcoded frame rate — see Clip preview thumbnails below for the fps default and how it's applied. A second watchdog timer polls whether the cursor is still actually over the row every 250ms — a deliberate backstop, since Enter/Leave events aren't always reliably delivered crossing child widgets, or into Maya's native viewport. The same watchdog-poll pattern is used independently by the gallery tiles in Clip Previewer for the same reason.

Assigning enable/disable layers

anim_layer_dialog.py's ClipAnimLayerAssignDialog(enable_names, disable_names, parent=None) — a three-column drag-and-drop shuttle (Disable | Available | Enable), distinct from AnimLayerBakeDialog (which bakes layers down before a Time Editor push; this dialog only assigns which layers get enabled/disabled at export time, never bakes anything). It has no knowledge of CFClip at all — purely two lists of layer name strings in, two lists out (.enable_layers() / .disable_layers()), so it's reusable independent of the clip row context. A layer name the clip already references that's no longer in the scene still round-trips through unchanged, marked "(not in scene)", rather than being silently dropped.

Status pill

status_chip.py's StatusChip(status, parent=None) — a small capsule button; clicking cycles ReadyInProgressIgnore → Ready. Emits statusChanged(new_status) on a user click-cycle only (set_status() itself doesn't emit). Its label/color lookup tables are keyed by ClipStatus.value (a plain int), never the enum member object.

Confirmed live: RefreshCoreTools' deepest-first reload order reloads this module before schema.py redefines ClipStatus as a new class, so a dict keyed by the old enum member object goes stale (Enum equality is identity-based) and raises KeyError on the very next status read anywhere else in the app. .value is a plain int and survives a reload unaffected — the same pattern clip_row_widget.py uses for its own status-color lookup.

What the colors mean

Every clip row carries two coordinated color cues — a thin stripe down its left edge, and a subtle full-card background tint blended from that same color — so a row's state reads at a glance without needing to open it or read the status pill's text. Only one color ever wins: current-clip overrides status outright (see Current-clip highlighting above), it never blends with whatever status color was showing.

Ready #7da1b7

This clip's animation is finished and expected to export cleanly. Light grayish-blue — deliberately calm, not a loud "go" green, so a list full of finished clips doesn't visually shout.

InProgress #d1913d

Still being worked on. Muted amber-orange — the same "attention, but not urgent" language the rest of CoreTools' theme reserves for its accent color.

Ignore #7a7d84

Excluded from export. Rendered as a flat neutral gray with no tint at all on the card background (only the status pill/stripe carry the color) — a row you're not exporting is meant to visually recede, not compete for attention.

Current clip #9c85b0

The playhead is somewhere inside this clip's frame range right now. Muted mauve/lilac — deliberately not reusing any other color in the palette, since this needs to read as its own, unmistakable signal. Applied at a stronger blend than status tints get, and always wins over whatever status color the row would otherwise show.

Driven by clip_row_widget.py's _refresh_card_style() — the stripe uses the color at full strength, the card background blends it into the row's normal gray gradient (theme.mix_color()) at a lighter ratio so the tint reads as a wash, not a solid fill.

That status tint is independent of the small round dot at the very left of each row - the dot is a per-clip custom color swatch (CF_ClipColor, set via "Set Color for Checked..." or clips.set_color()), not a status indicator. A clip can be Ready and carry its own custom dot color at the same time - useful for grouping clips visually by character, sequence, or any convention a team wants, on top of (not instead of) status:

Four clip rows, each with its own custom color swatch dot
Custom color swatches

Four different clips, four different user-assigned dot colors - independent of whatever each clip's own status happens to be.

Time Editor integration

time_editor_sync.py — reads and writes Maya's own Time Editor clips, independent of any Time Editor panel being open (confirmed live: pure node/attribute queries via cmds.timeEditorTracks/timeEditorClip, no UI/panel state needed for reading).

Reading: TE → AnimHub

Tools > Sync Clips to Timeline Editor reads whatever's currently in the Time Editor and creates/updates matching AH clips by name. This is a one-time snapshot, not a live link — a later Time Editor edit has no effect on the AH clip until Sync is run again. Time Editor is treated as the source of truth for this direction.

TimeEditorSyncDialog(te_clip_count, existing_ah_clip_count, parent=None) is the options prompt: a label reporting how many Time Editor clips were found; if AH already has clips, a radio choice between Add/Update (default — merge into existing AH clips by name match) and Replace All (clears every existing AH clip first); and always a second radio choice between Keep Animation in Time Editor (default) and Bake Keys Back to Scene (restores real scene keyframes for the ranges just adopted, via timeEditorBakeClips, then removes the TE clips).

Writing: AnimHub → TE

Tools > Push Clips to Timeline Editor (push_clips_to_time_editor) creates one Time Editor clip per AH clip, all driven from a single timeEditorAnimSource built from the current selection — or, if nothing is selected, from every animated object in the scene (list_animated_objects(), which correctly traces through anim-layer blend-node chains to the real driven transform, not just immediate curve connections).

Hierarchy expansion: an explicit selection is expanded to include every descendant transform/joint (_include_hierarchy) before pushing. Without this, selecting just a rig's root and pushing left every child joint's keys untouched by the resulting clip's own retiming in the Time Editor.

Anim Layer check before push: if any selected object has a genuinely anim-layer-driven attribute, a dialog offers to bake the layers down first — see Anim Layer detection and baking.

A note on batching (push_clips_to_time_editor_batched): an earlier version split the push across many single-object batches, specifically to dodge a confirmed intermittent hang in Maya's own timeEditorAnimSource -addSelectedObjects command on one large/complex production rig (60 objects worked once then hung later at the same size; only calling it one object at a time was 100% reliable across 144 controls, zero failures). That batched path is not the default — the single-call version above is, since batching fragmented every normal push into one clip per object, needing a second grouping pass (group_clips_by_name) just to be usable again. Kept available for the pathological large-rig case, callable directly, but not wired into the UI.

Bookmark sync

Every AH clip gets a paired Maya timeSliderBookmark node (bookmark_sync.py), kept live-synced (name/start/end/color) on every edit to the clip — so Maya's own native Bookmark Editor and timeline ruler always reflect the current clip list.

Deliberately a plain stored string (CF_ClipBookmark), not a connection. Confirmed live: deleting a timeSliderBookmark node cascades to delete anything connected to it, in either direction, even a single non-message-type connection. A connection-based design would let a user deleting a bookmark in Maya's own Bookmark Editor silently delete the paired AH clip too — the string-plus-explicit-setAttr design avoids that entirely; deleting the bookmark only orphans the string reference, never cascades into the clip.

A marker boolean attribute on every AnimHub-generated bookmark (is_ah_generated_bookmark()) lets other code (notably the one-shot bookmark importer) distinguish AH's own shadow bookmarks from a user's real, independently-created ones, robust even if the owning clip was later deleted outside the normal API.

If a bookmark already has an existing connection from some other tool (e.g. a legacy pipeline's own bookmark-driven navigation system) when AnimHub takes it over, that connection is force-broken rather than left in place — AnimHub always takes full ownership of a bookmark it's synced to.

Anim Layer detection and baking

anim_layer_bake.py — used before a Time Editor push (and available generally) to detect and optionally flatten anim-layer-driven animation.

animhub/anim_layer_bake_dialog.py (AnimLayerBakeDialog) is the CF UI for this: shows the affected attribute count and any locked layers, with a "temporarily unlock these layers for this bake" checkbox (unchecked by default), and Bake & Continue / Continue Without Baking / Cancel.

Scene retiming

scene_retime.py's normalize_start_to_zero(objects=None) shifts every keyframe on the given objects (or every animated object in the scene) so the earliest key lands at frame 0, in one pass over the underlying animCurve nodes — not per-object. This exists specifically because a naive two-pass approach (shift once via each object's own transform, once via the animCurve nodes directly) shifts the same underlying curve twice through two different paths, silently doubling the offset. This is exposed to the AI Companion as clips.offset_all_keys_to_start_at_zero() — see ClipsAPI.

Export pipeline

export_pipeline.py — given a clip and its export-character slots, resolves an output path, sets up scene state, and calls Maya's FBXExport.

Path resolution order (first match wins):

  1. clip.override_path (a folder), if set — wins over everything.
  2. The slot's own explicit path (a full file path).
  3. Project-root convention: <project_root>/Animations/<CharacterExportName>/<CharacterExportName>_<ClipName>.fbx, via core.project.get_project_root().
  4. If no project root resolves, Settings > AnimHub > Export's "Global export folder."
  5. Last resort: <scene_dir>/fbx_export/<...>.fbx.
Paths are always normalized to forward slashes before being handed to mel.eval() — confirmed live that an unnormalized Windows path gets silently corrupted by MEL's own escape-character parsing, causing an export that reports success while writing to the wrong location.

Settings > AnimHub > Export is also where tier 3's project root mode (Maya Project / P4 Workspace / Custom) is configured — it used to live on the generic Settings > Project page, but moved here since this export path resolution was always its only real consumer. It still reads/writes the exact same "project" config keys core.project.get_project_root() itself reads, regardless of which UI wrote them — only the control surface moved, not the underlying shared config.

Bake-at-origin (a character with bake_mode == BakeMode.ORIGIN): duplicates the export hierarchy, unparents it, parentConstrains the duplicate back to the original, bakes only the duplicate's root transform channels, deletes the constraint. This captures the original's real world-space motion into the parentless duplicate's local channels — it does not "zero the root," which would erase motion rather than re-origin it. Children keep their existing local (skin/rig-driven) animation untouched. World-mode characters (the default) export the real node directly, no duplication.

FBX flags: no input connections, no cameras/lights, no constraints/instances/smoothing groups, bake complex animation at step 1, split into takes.

File Type (ASCII/Binary) is a Settings > AnimHub > Export control too, defaulting to ASCII. This pipeline embeds real per-clip metadata onto the exported node (the Loop flag below), and ASCII keeps that inspectable/diffable the way this pipeline is meant to be used — switching to Binary shows a confirmation first (reverts to ASCII if declined) rather than silently changing it. Binary is still fully supported once confirmed.

Loop flag: clip.loop gets baked onto the exported node as a keyed boolean custom attribute (CF_Loop), keyed at both start and end (even though constant) because FBX's bake-complex-animation step only reliably exports attributes that actually have keyed animation.

Perforce: if P4 is available, add_or_checkout()s the output path before writing.

No AI-callable export method exists anywhere — see ClipsAPI. Export is deliberately restricted to a human clicking a real button (Export FBX, the per-row export button, or background export).

Entry points: export_clip(clip) (one clip, every enabled slot), export_clips(collection, force_all=False, on_progress=None) (batch — defaults to collection.checked(), or every non-Ignore clip if force_all=True).

Anim layer scoping at export

layer_manager.py maps a clip's enable_layers/disable_layers onto real animLayer mute state, scoped to just the layers named — every other layer's mute state is left untouched. If a layer name appears in both lists, disable wins (applied second, unconditionally). LayerScope(clip) is a context manager: snapshots every layer's mute state on enter, applies the clip's config, and restores the exact snapshot on exit (even on exception) — export_clip() wraps its whole per-character-slot export loop in this, so an export never leaves the scene's layer visibility changed afterward.

Clip preview thumbnails

clip_preview.py captures small (220x124) JPEG frame-sequence playblasts per clip for the hover-preview thumbnail — separate from any full-resolution review playblast. Requires the scene to already be saved (previews live at <scene_dir>/_previews/<clip_name>/) — the most common capture_preview() failure. Old frames are deleted before a new capture, so a shortened clip doesn't leave orphaned trailing frames from a longer previous one.

thumbnail_render.py composites a source frame into a rounded-corner thumbnail (compose_thumbnail_pixmap) — a QLabel stylesheet's border-radius only rounds the label's own background, not a pixmap drawn on top, so this does the rounding itself onto a real-alpha QImage. HoverPreviewThumbnail is the reusable widget (used both inline in a clip row and in the Clip Previewer's gallery tiles) that animates through captured frames on hover, sharing one playback speed with ClipPreviewPopup so nothing descended from either runs at a different cadence.

Playback speed is a live, user-adjustable settingSettings > AnimHub > Interface's "Preview playback speed" (fps, default 52, i.e. ~19ms/frame). clip_preview.frame_interval_ms() reads it fresh from UserConfig on every call, no caching; both ClipPreviewPopup.start() and HoverPreviewThumbnail.start_animation() re-read it and re-apply it to their own QTimer via setInterval() at the START of every hover, not just once at construction — so a Settings change takes effect on the very next hover in an already-open window, no RefreshCoreTools or Maya restart needed. This setting replaced a series of hardcoded passes (80ms → 33ms → 16ms → 19ms) before it became clear this just needed to be a real, user-facing setting.

Clip Previewer (gallery window)

clip_previewer_window.pyCFClipPreviewerWindow, opened from Tools > Clip Previewer or the corner toolbar's dedicated launch button. A standalone dockable gallery view (search + group filter, a responsive grid of preview tiles) as an alternative to AnimHub's own text/field-dense row layout. Reads the same scene clips (model.ClipCollection) but is fully decoupled — neither window owns the other, and both read the same scene-level group registry so a group created in AnimHub's own Groups dialog shows up here automatically (this window can filter by group, not create/rename/delete one).

AnimHub's main window docked beside the standalone Clip Previewer gallery window
AnimHub + Clip Previewer, side by side

The Clip Previewer (right) is a separate top-level window, not a panel bolted onto AnimHub (left) — each tile mirrors one row's clip and renders the same captured preview frames as the row's own hover popup, just larger and always visible instead of appearing on hover.

Each tile is a ClipPreviewTile(clip, size, parent=None) (clip_preview_tile.py) — a bigger, click/right-click-driven sibling of the row's own "Preview"-style inline thumbnail, sharing the same thumbnail_render.HoverPreviewThumbnail rendering/hover-animation logic rather than reimplementing it. Left-click emits syncRequested if the clip already has a preview, or generateRequested if not ("click to generate"); right-click offers Sync Take / Generate Clip Preview / Delete Preview (shown-but-disabled without a preview) / Export (calls export_pipeline.export_clip directly).

This window's own Tools menu also has scene-wide "Generate Missing Previews" / "Delete All Previews" — the same two actions AnimHub's own Clips > Clip Preview submenu offers, operating on the same collection either way. Both always operate on the entire scene collection regardless of the current search/filter, so a maintenance sweep's definition of "all" doesn't silently depend on whatever's typed in the search box.

The grid's column count recomputes on every resize based on the actual scroll-area width — a wide floating window shows several tiles per row, a narrow docked panel collapses to one column.

Groups

Clips > Manage Groups opens ClipGroupsDialog (groups_dialog.py) — a three-column shuttle (All Clips | Groups | Clips in Group) for creating/renaming/deleting groups and managing membership, with independent filters per column. Every action writes through immediately, same live-write convention as the rest of the app. "All" is reserved and can't be used as a group name. Deleting a group only removes clip membership in it — clips themselves are untouched.

Remember the two-layer model from Clip data model: this dialog is the only place group registration (what makes a group a real, orderable tab) happens — tagging a clip's CF_ClipGroups elsewhere (including via ClipsAPI.add_to_group()) never creates the tab by itself.

Batch rename

Clips > Batch Rename (batch_rename_dialog.py) — prefix, suffix, find/replace (literal or regex), and optional zero-padded sequential numbering. Operates on checked clips if any are checked, else every clip. The core renaming logic, apply_rename(name, index, params), is a pure function with no widget/clip dependency — order of operations is find/replace first, then prefix+name+suffix, then (if enabled) the sequential number appended last. Invalid regex is caught and silently no-ops for that clip rather than raising.

Clip Shuttle (floating nav bar)

Tools > Clip Shuttle (clip_shuttle_dialog.py) opens a small, frameless, translucent, always-on-top pill mirroring the main window's Prev/Current/Next/Checked/All actions, so you can scrub clips without the full AnimHub window in the way. It's a thin remote control — every button delegates straight to the owning main window's own methods, it doesn't reimplement any nav/range logic itself. Non-modal (.show(), stays open alongside the main window).

Import/export clip metadata

File > Import/Export Clips (clip_io.py) snapshots/restores clip metadata (name, range, status, checked, color, groups, layers, loop, curve-data flags, override path) to/from a plain JSON file — distinct from FBX export, which writes animation data, not this app's own bookkeeping. Import matches an existing clip by name only (no uuid/node matching) — creating a new clip if no name match is found, otherwise updating the existing one's fields in place. Renaming a clip between an export and a later import will create a duplicate rather than update the original clip.

Background (headless) export

Tools > Export Clips FBX on Background launches tools/headless_export_clips.py as a genuinely separate mayapy subprocess (not Maya's own background evaluation) via QProcess, streaming output into a live console and a timestamped log file. The scene must already be saved (you're prompted to save first). The headless script opens the saved scene fresh, exports every checked clip (no force_all option here), and writes a JSON results summary the main window reads back for a final success/failure dialog.

One-shot bookmark import

Clips > Import from Bookmark Manager... (tools/sync_clips_with_bookmarks.py) is a one-directional, one-shot migration utility: reads every real timeSliderBookmark in the scene and creates a new AH clip for any that don't already match an existing clip by name, best-effort copying the bookmark's color too. It explicitly skips AnimHub's own generated bookmarks (via bookmark_sync.is_ah_generated_bookmark()) to avoid re-importing AH's own shadow bookmarks as duplicate clips. This is not an ongoing sync — re-running is safe (already-matched names are skipped), but nothing here keeps bookmarks and clips linked afterward.

Each newly created clip adopts the bookmark it was imported from (bookmark_sync.adopt_bookmark_for_clip()) rather than getting its own brand-new one. CFClip.create() (used internally by collection.add()) always makes its own bookmark unconditionally via ensure_bookmark_for_clip() — without the adopt step, that left two identical-looking bookmarks (same name/range) sitting side by side in Maya's own Bookmark Manager after every import, a real reported bug. The redundant auto-created one is deleted immediately after the real one is adopted.

Game Exporter integration

Tools > Game Exporter > Import Clips from GE / Push Clips to GE (game_exporter_sync.py) — one-shot, one-directional syncs with Maya's own built-in Game Exporter (Windows > Animation Editors > Game Exporter), matching the same non-live convention as the bookmark import above.

Game Exporter has no cmds.gameFbxExporter command and no Python module at all — confirmed directly against Maya's own installed MEL source. Its clip list lives entirely as attributes on a custom node type (gameFbxExporter, one node per preset shown in its own preset dropdown), a multi-attribute array .animClips[i] = {animClipName, animClipStart, animClipEnd, exportAnimClip, animClipSrcNode, animClipId}. This is read/written directly via plain cmds.getAttr/setAttr — the same mechanism Maya's own Game Exporter UI code uses internally. The node type's plugin (gameFbxExporter.mll) isn't loaded by default — confirmed live it must be explicitly loaded (cmds.loadPlugin("gameFbxExporter")) before the node type is even recognized, the same requirement bookmark_sync.py already has for timeSliderBookmark.

Import Clips from GE reads the first gameFbxExporter node found in the scene and creates a new AH clip for every entry whose name doesn't already match an existing clip — same skip-on-collision convention as bookmark import. No node in the scene yet means nothing to import (not an error).

Push Clips to GE opens a small dialog (GameExporterPushDialog) asking which clips to push — All Clips (the default) or Checked Clips. Each pushed clip is matched into the Game Exporter's list by name — an existing entry's range is updated in place, a new one is appended otherwise, so re-running is idempotent and never creates duplicates. If no gameFbxExporter node exists yet, one is created (named CF_GameExporterPreset, forced onto the Animation tab) — an existing node's own export-type setting is left alone. If the Game Exporter window happens to be open already, its own clip list view is refreshed afterward.

AI Companion integration

Tools > Ask AI opens the shared AI Companion panel and binds it with two objects scoped entirely to AnimHub's own domain, plus a "concepts primer" describing how to use them — full reference in ClipsAPI. The AI never gets raw, unrestricted access to the scene here: it can only act through these two curated, tested surfaces, and even then only after a human reads the proposed Python and clicks Run.

C

clips (ClipsAPI)

AnimHub's own clips: clips.create()/clips.offset_all_keys_to_start_at_zero()/etc.

T

time_editor (TimeEditorAPI)

Maya's own, separate Time Editor clips (time_editor_sync.py) — a completely different feature that just shares a similar name. time_editor.list_clips() and time_editor.resolve_overlaps() (de-overlaps every Time Editor clip, preserving each one's own duration) are the only two methods exposed.

Added after a real request ("de-overlap my Time Editor clips") first got refused outright, then got hallucinated cmds.timeEditorClip/timeEditorTracks flags on every freehand attempt. The primitive itself had a real bug in its first version too: repositioning via edit-mode trimStart/trimEnd reports the right boundary numbers but silently corrupts the clip's actual playback (those flags are relative to the clip's ORIGINAL creation anchor, not its current position — moving it far enough samples past its real keyed range into frozen hold/extrapolation instead of the real animation). _move_clip() was rebuilt around delete-and-recreate-from-the-same-anim-source instead, this time verified by sampling actual driven attribute values before/after a move, not just boundary numbers.