Camera Motion Trajectory from Images
Ken Burns effects with a keyframed JSON spec
- image
- depth_map
- video
- frame_count
Before I2V models got good at inventing plausible motion from a still, the standard way to turn a photo into "video" was the Ken Burns effect - pan, zoom, maybe a slow rotate, entirely deterministic, no new content generated. This node is that technique, done properly: a keyframed camera path (pan, tilt, rotation, zoom, plus dolly and a "sphereize" warp) over a still image, with easing between keyframes, producing an actual frame sequence without touching a video model at all.
How it works
The whole trajectory is defined in one place: trajectory_spec, a multiline JSON blob with a list of keyframes, each specifying a frame number and the camera state at that point - zoom, center, angle (rotation), pan, tilt, plus dolly_strength/dolly_radius/dolly_feather/dolly_mode for a push-in/out effect, and sphereize_strength/sphereize_radius/sphereize_feather for a fisheye-style warp. Each keyframe also carries its own ease curve, and there's a top-level default_ease and loop flag for the whole sequence. There's no visual curve editor here - the JSON is the interface, and the node ships with a working three-keyframe example (zoom 1.0 → 1.5 → 2.0, rotating a full 360° across frames 0/30/59) that's worth running once as-is before writing your own.
The optional depth_map input is what elevates this past a flat 2D pan-and-zoom: feed it a depth estimate of your image (from a depth estimation node) and the camera move gets real parallax - near objects shift more than far ones as the "camera" moves, instead of the whole image sliding as one flat plane. Without a depth map, you get a straightforward 2D Ken Burns move; with one, you get something closer to a fake dolly shot.
The inputs and outputs that matter
image(IMAGE) - the still you're animating.num_frames(default 60, range 1–2048) - how many frames to render. Should generally match the frame range yourtrajectory_speckeyframes actually cover.trajectory_spec(multiline STRING, JSON) - the entire camera path. This is the node's real interface; everything else is secondary.edge_mode(border / mirror / wrap, default mirror) - how to fill pixels that get revealed at the frame edge once you pan or zoom out. Matters as soon as your trajectory pushes content past the original image bounds.depth_map(optional, IMAGE) - feed this for parallax/dolly effects; without it, motion is flat.
Two outputs: video (IMAGE, a batch of rendered frames) and frame_count (INT), which is convenient to wire onward into anything downstream that needs to know how many frames you actually produced.
How to install it
Via ComfyUI Manager: search "WAS_Extras", install, restart. Manually:
cd ComfyUI/custom_nodes
git clone https://github.com/WASasquatch/WAS_Extras
Restart ComfyUI. No extra dependencies for this node.
Common issues & troubleshooting
Edges of the frame look smeared or repeated once the camera pans. That's edge_mode. mirror is usually the least jarring default for photos; border repeats edge pixels outward (can look like smearing on a wide pan); wrap tiles the image, which is rarely what you want outside of deliberately tileable source images.
The trajectory doesn't seem to do anything, or errors out. Check your trajectory_spec is valid JSON first - a malformed edit (a missing comma, an unclosed bracket) is the most likely cause if you were hand-editing the default spec rather than replacing it wholesale.
Motion looks flat and "slidey" rather than like a real camera move. That's expected without a depth_map - a flat pan/zoom moves every pixel by the same amount regardless of what's near or far in the scene. Feed it a depth map from a depth estimation node if you want actual parallax.
Camera move feels too smooth or mechanically perfect. That's what enable_camera_shake (with shake_position_amplitude, shake_rotation_amplitude, and shake_seed) is for - it layers a small amount of handheld-style jitter on top of the deterministic path, which can make an otherwise perfectly clean move read as less artificial.
Inputs (9)
| Name | Type | Default | Description |
|---|---|---|---|
| image | IMAGE | — | |
| num_frames | INT | 601–2048 | — |
| trajectory_spec | STRING | { "loop": false, "default_ease": "linear", "keyframes": [ { "frame": 0, "zoom": 1.0, "center": [0.5, 0.5], "angle": 0.0, "pan": [0.0, 0.0], "tilt": [0.0, 0.0], "dolly_strength": 0.0, "dolly_radius": [0.3, 0.3], "dolly_feather": 0.5, "dolly_mode": "radial", "sphereize_strength": 0.0, "sphereize_radius": [0.4, 0.4], "sphereize_feather": 0.5, "depth_strength": 0.0, "ease": "ease_in_out" }, { "frame": 30, "zoom": 1.5, "center": [0.5, 0.5], "angle": 90.0, "pan": [0.0, 0.0], "tilt": [0.0, 0.15], "dolly_strength": 0.3, "dolly_radius": [0.35, 0.35], "dolly_feather": 0.5, "dolly_mode": "radial", "sphereize_strength": 0.0, "sphereize_radius": [0.4, 0.4], "sphereize_feather": 0.5, "depth_strength": 0.0, "ease": "ease_in_out" }, { "frame": 59, "zoom": 2.0, "center": [0.6, 0.4], "angle": 360.0, "pan": [0.1, 0.0], "tilt": [0.0, 0.3], "dolly_strength": 0.6, "dolly_radius": [0.4, 0.4], "dolly_feather": 0.5, "dolly_mode": "radial", "sphereize_strength": 0.0, "sphereize_radius": [0.4, 0.4], "sphereize_feather": 0.5, "depth_strength": 0.0, "ease": "linear" } ] } | — |
| edge_mode | COMBO | mirror | 3 options: border, mirror, wrap |
| enable_camera_shake | BOOLEAN | false | — |
| shake_position_amplitude | FLOAT | 0.0300–0.5 | — |
| shake_rotation_amplitude | FLOAT | 1.50–45 | — |
| shake_seed | INT | 00–2147483647 | — |
| depth_mapopt | IMAGE | — |
Outputs (2)
| Name | Type | Description |
|---|---|---|
| video | IMAGE | — |
| frame_count | INT | — |