12 KiB
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
sourceconvention: pass a stable, unique token to mutators you call (e.g."my-node-42"), and in your listenerif (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 fromapp.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.jsand 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?
bounddistinguishes "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 |