Files

12 KiB
Raw Permalink Blame History

ComfyTimeline — API Reference (apiVersion 1.0.0)

The committed stable surface. Anything not listed here (notably _-prefixed members) is internal and may change without a major bump.

  • Additive changes (new methods/getters/events) bump the minor version.
  • Breaking changes bump the major version and are documented in a migration note.

window.ComfyTimeline

The global singleton, published on page load. ES-module consumers may instead import { getTimeline } from ".../timeline_model.js".

window.ComfyTimeline = {
  apiVersion:    "1.0.0",        // semver of THIS surface
  schemaVersion: 1,              // integer; app.graph.extra.timeline serialization only
  ready:         Promise<self>,  // resolves once bootstrapped

  model:     TimelineModel,      // the default timeline (== timelines.default)
  curves:    CurveStore,         // global curve registry
  timelines: TimelineRegistry,   // named timelines

  Curve, CurveStore, TimelineModel, TimelineRegistry,   // classes
}

Capability detection

const T = window.ComfyTimeline;
if (!T || parseInt(T.apiVersion, 10) < 1) return;   // absent or too old → no-op

Access it inside your extension's setup() (runs after all extensions register) so module load order doesn't matter. The Python side mirrors this with a soft try/except ImportError around import comfy_timeline.


TimelineModel (extends EventTarget)

Single source of truth for time state. Two ranges, Maya-style: anim [animStart, animEnd] (full authored extent) and play [playStart, playEnd] (visible/scrubbable window transport loops over).

Fields (read)

currentFrame, animStart, animEnd, playStart, playEnd, fps, playing, loop, playbackSpeed, playbackMode ('realtime'|'everyframe'), name.

Read these freely. Write only via the mutators below — they enforce ordering/clamping and emit events. The playback loop self-guards against tampered/inverted state, but direct field writes won't notify subscribers.

Derived getters (read-only)

getter returns
duration play-range frame count (playEnd-playStart+1)
animDuration anim-range frame count
progress playhead position 0..1 within the play range
isFrameInPlayRange(f) / isFrameInAnimRange(f) bool
clampToPlayRange(f) / clampToAnimRange(f) clamped frame
timeAtFrame(f) seconds since playStart at fps
frameAtTime(s) frame at s seconds (rounded)
snapshot() plain object of all state incl. playing

Mutators (all take an optional source token)

method effect / event
setCurrentFrame(frame, source) move playhead → frame
setAnimRange(start, end, source) set anim range (clamps play range inside) → range
setPlayRange(start, end, source) set play range (clamped to anim) → range
setFps(fps, source) set fps (>0) → fps
setPlaybackSpeed(speed, source) playback rate multiplier (>0) → fps (with detail.speed)
setPlaybackMode(mode, source) 'realtime' or 'everyframe' → mode
setLoop(bool, source) → loop
play(source) / pause(source) / togglePlay(source) transport → play
step(delta, source) ±frames, wrapping within the play range → frame
goToStart(source) / goToEnd(source) jump to play range ends
reset(source) pause + restore defaults (0–100, fps 24, loop, frame 0)

Persistence

method
serialize() plain JSON-safe snapshot (excludes playing)
load(data) apply a snapshot; version-guarded, tolerant of partial/hostile payloads; emits source:'load' events. Rides app.graph.extra.timeline automatically (synchronous small-state write; rehydrate on afterConfigureGraph).

Subscription

const off = model.subscribe("frame", cb);          // or subscribe(["frame","range"], cb)
off();                                              // unsubscribe all

Equivalent to addEventListener per event, but returns one teardown.

Events

See the table in the README. Every detail includes the originating source token for echo-suppression.

The source convention: pass a stable, unique token to mutators you call (e.g. "my-node-42"), and in your listener if (e.detail.source === MY_TOKEN) return; so you don't re-handle your own edits. Built-in sources actually emitted by this pack: 'bar' (the bottom-panel bar), 'playback' (the rAF transport loop), 'load' (rehydrate from app.graph.extra). (The auto-bind is read-only — it mirrors model→widget and never calls a mutator, so it emits no source.)

Playback robustness: the rAF loop clamps dt (no teleport when a background tab resumes), reschedules before emitting (a throwing subscriber can't freeze transport), and pauses on an inverted/zero range.


CurveStore (extends EventTarget)

Map<curveId, Curve> — curveId is caller-defined. The built-in editor uses its LiteGraph node id, adding an ephemeral scope suffix only when old subgraphs reuse the same numeric id.

method
register(curveId, curve) / unregister(curveId) add/remove; emits change
get(curveId) / has(curveId) / ids() / entries() / forEach(fn) / size access
touch(curveId) re-publish after in-place key edits; emits change
clear() drop all
prune(validIds) drop curves whose id isn't in validIds (Set/array); returns count
mergedFrames({ curveIds }) sorted unique union of key frames (all curves, or a subset)

Event: change → { curveId }.

Keyframe ticks on the bar (dope-sheet)

Registered curves render as merged keyframe ticks on the global bar (those of the selected authoring nodes, else all). The ticks are editable: drag L/R to retime, click to select, Delete/Backspace to remove, Escape to deselect — across every relevant curve at that frame. Value/tangent editing stays in the curve node's 2D graph.

The bar treats the authoring node as the data authority: built-in curves carry their owning node reference, with public graph.getNodeById() as the root-graph fallback. It calls these methods so curve keys, the 2D view, the CurveStore, and Python keys_store stay in sync. A third-party node that wants its registered curve bar-editable should expose the same on its editor instance:

hasKeyAtFrame(frame): boolean
barMoveKey(fromFrame, toFrame): boolean   // live retime; re-sync store, no persist
barDeleteKey(frame): boolean              // delete (keeps >= 2 keys); persists
barCommit(): void                         // flush a drag run to persistence

// OPTIONAL (feature-detected by the bar). When implemented, a tick
// drag is applied against a snapshot of the pre-drag key set: a key
// covered by the dragged key is suppressed, not destroyed — dragging
// back off it restores it. Without them, each barMoveKey collision
// merges permanently.
barBeginDrag(frame): boolean              // open a snapshot for the key at frame
barEndDrag(): void                        // close the snapshot (barCommit persists)

A move that would leave fewer than 2 keys is refused (barMoveKey returns false), matching barDeleteKey's invariant. A curve registered without these methods still renders ticks; they just aren't draggable/deletable on the bar.


Curve

new Curve(keys?)            // keys: [{ frame, value, in_type?, out_type?, in_slope?, out_slope? }]
member
keys normalized, frame-sorted, de-duplicated array
setKeys(keys) replace all (returns this)
sample(frame) tangent-aware value (matches the Python sampler)
addKey(key) insert/replace at rounded frame
removeKey(frame) returns bool
moveKey(from, to, value?) move (drops a collision at to); returns key or null
frameRange [min, max] or null

Curve.normalize(keys) (static) is the same coercion/sort/dedupe: frames are canonical integers (Math.round) and the last key in a rounded-frame bucket wins.


TimelineRegistry

Named timelines. window.ComfyTimeline.model aliases timelines.default.

method
default the main TimelineModel
getOrCreate(name) get or lazily create a named TimelineModel
get(name) / has(name) / names() / entries() access

v1 persists the default timeline to app.graph.extra.timeline. Named timelines are in-memory; persisting additional ones is a planned addition.


Bar extension point + commands

Add a control (button, readout, …) to the bar's transport row without forking:

window.ComfyTimeline.bar.registerControl({
  id: "my-ext.snap",
  render(hostEl, { model, curves }) {
    const b = document.createElement("button");
    b.textContent = "⤓"; b.title = "Snap to nearest key";
    b.onclick = () => model.setCurrentFrame(/* … */);
    hostEl.appendChild(b);
  },
});

Controls render at the right end of the transport row (re-rendered live if the bar is already open). A throwing control can't break the rest of the bar.

Transport commands (registered by the pack; in the command palette under "Timeline:", rebindable in Settings → Keybindings): ComfyTimeline.PlayPause, ComfyTimeline.NextFrame, ComfyTimeline.PrevFrame, ComfyTimeline.GoToStart, ComfyTimeline.GoToEnd. Defaults: Space, →, ←. The auto-created Workspace.ToggleBottomPanelTab.comfy-timeline (default Alt+T) opens the tab.

Not yet exposed: pluggable interpolators (custom curve types). Curve interpolation is fixed (Hermite/linear/stepped) and is byte-for-byte mirrored between sampler.js and the Python sampler; a plug-in surface needs a JS↔Python parity contract first, so it's deliberately deferred rather than shipped half-done.

Python — comfy_timeline

from comfy_timeline import (
    timeline_inputs, read_timeline, TimelineState,
    TIMELINE_INPUT_NAMES, FRAME_INPUT, START_INPUT, END_INPUT, FPS_INPUT,
    __version__,
)

timeline_inputs(*, frame=True, range=True, fps=True, optional=True) -> list

Returns the standard io.*.Input defs to splat into a node's schema: current_frame (INT), start_frame (INT), end_frame (INT), fps (FLOAT). Integer inputs set control_after_generate=False so no increment combo fights the timeline. Keep them as widget inputs — never force_input.

read_timeline(**kwargs) -> TimelineState

Unpacks the standard kwargs (ignores the rest). Coerces frame/range to int, fps to float; rejects bool/NaN/inf/garbage → defaults.

class TimelineState(NamedTuple):
    current_frame: int
    start_frame: int
    end_frame: int
    fps: float
    bound: bool      # were timeline inputs actually supplied to read_timeline?

bound distinguishes "called with timeline inputs" from "called with only unrelated kwargs". It does not detect "frontend pack absent" — Python only sees the prompt value, which is the widget default when nothing drove it.

Why widgets, not server state

ComfyUI re-runs a node only when its inputs change. Because current_frame is a serialized widget input, scrubbing the bar (which mirrors into the widget) changes the prompt and correctly re-runs the node. A server-side "current frame" read inside execute() would be cached and never refresh on scrub.

The flip side: every declared input is part of ComfyUI's cache key, so only declare current_frame when your output genuinely depends on the playhead. The pack's own nodes (TimelineAnimation, TimelineImageViewer, Timeline3DViewer) deliberately do NOT declare it — their output is playhead-independent, and a current_frame widget would re-run them (and everything downstream) on every scrub+queue. Render-only playhead display belongs in JS, read from window.ComfyTimeline.model.


Migration from window.timelineAnim

The pre-1.0 cross-node registry (window.timelineAnim with setCurrentFrame/setStartFrame/listeners) is removed. Replace:

old new
window.timelineAnim.currentFrame window.ComfyTimeline.model.currentFrame
window.timelineAnim.setCurrentFrame(f, src) window.ComfyTimeline.model.setCurrentFrame(f, src)
window.timelineAnim.listeners.add(cb) window.ComfyTimeline.model.subscribe("frame", cb)
per-node scrub strips the global bottom-panel bar