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 | The picture the camera moves over. A batch is read as source frames: when it holds exactly num_frames images each output frame uses its own, otherwise the batch is cycled. | |
| num_frames | INT | 601–2048 | How many frames to render. At 24 frames per second, 60 frames is two and a half seconds. Keyframe numbers in the spec are held inside this range. |
| 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" } ] } | The move, as JSON: a 'keyframes' list, each entry carrying a 'frame' number and the camera properties it sets there. Empty text holds the picture still. |
| edge_mode | COMBO | mirror | What fills the frame when the camera looks past the edge of the picture. `mirror` reflects the picture back, which is the least visible; `border` smears the edge pixels; `wrap` brings the opposite edge round, which suits a seamless texture. |
| enable_camera_shake | BOOLEAN | false | Whether to add a handheld wobble on top of the keyframed move. Off gives a locked-off, tripod-steady result; on makes the shot feel operated by a person. |
| shake_position_amplitude | FLOAT | 0.0300–0.5 | How far the wobble drifts, as a share of the frame. 0.01 is a barely visible breath, 0.03 a natural handheld hold, 0.2 a running shot. Ignored while enable_camera_shake is off. |
| shake_rotation_amplitude | FLOAT | 1.50–45 | How far the wobble rolls, in degrees. 1.5 reads as a steady hand, 10 as an unsteady one. Set to 0 for drift without any roll. Ignored while enable_camera_shake is off. |
| shake_seed | INT | 00–2147483647 | Seed for the wobble. The same seed always produces the same wobble, so a shot can be re-rendered identically; change it to try another take. |
| depth_mapopt | IMAGE | Optional depth map, white near and black far, at any size. With one connected, 'depth_strength' in the spec holds the far parts of the scene back while the near parts move fully, which is what makes the shot read as parallax. Leave it unconnected for a flat move. |
Outputs (2)
| Name | Type | Description |
|---|---|---|
| video | IMAGE | The rendered frames in order, all at the input's size, ready for a video writer or an image-to-video model. |
| frame_count | INT | How many frames were rendered, for wiring straight into a video writer's frame count or a duration calculation. |