---
title: Make a Spotify Canvas and a music visualizer
description: A Spotify Canvas loop for your track, and a music visualizer video where shapes and effects pulse on the beat of your song.
---
You get two things for a song. A Spotify Canvas is a short vertical loop that plays behind your track in the Spotify app. A music visualizer, also called an audio visualizer or audio-reactive video, is a full-length video where shapes and effects pulse on the beat, ready for YouTube.

Use invideo for this when you want the motion to follow your song without timing it by hand: your agent reads the beats and the levels of the track, and turns them into keyframes you can still change on the timeline.

## Quickest path

Set up your agent first with [invideo for AI agents](/agents). Then give it this prompt.

```text
Here is my song, ./night-drive.wav, and the cover art, ./cover.png.
Make a Spotify Canvas: a short vertical loop from the cover, slow
neon motion, no text. Then make a 16:9 visualizer for YouTube where a
glowing circle pulses on every beat. Show me both before you export.
```

In a chat app, you drag the song and the cover into the project's Media panel in invideo. In a terminal, you name the files and the agent uploads them.

## What you need

Use files you own or have the rights to.

- **The song.** WAV or MP3, the final master.
- **The art.** Your cover art, a photo, or a clip. The agent can also generate the scene.
- **The look.** Colors, mood and motion: "slow neon", "grainy film", "bright and fast".
- **The Canvas spec.** Spotify wants a vertical 9:16 video that is a few seconds long and loops. Read the exact spec in <a href="https://support.spotify.com/us/artists/article/canvas-guidelines/" target="_blank">Spotify's Canvas guidelines</a>.
- **The visualizer shape.** 16:9 for YouTube, or 1:1 or 9:16 for a social post.

## Steps

The first three steps make the Canvas. The rest make the visualizer. Your agent edits on a branch and tells you what changed at each step.

### Make the Canvas

The Canvas is a short looping video for the Spotify track page.

<Steps>
<Step title="Make the Canvas loop">
The agent brings in the cover with `add_media`. With a first frame, `seedance_2_5.frames` takes the shape of that image and ignores `aspect_ratio`. So the agent first makes a 9:16 copy of the cover with `media` `image.resizeAspectRatio` on the cover's sequence URL from `add_media`, and looks at it with `video.frames` to see whether the op cropped or padded the art before it spends anything. It runs `find_models` with "looping video from an image". A good pick is `seedance_2_5.frames`. Your agent can search for others with `find_models`. The agent reads the pick with `get_model`. It calls `generate` with `dry_run: true`, the 9:16 cover as both `first_frame` and `last_frame`, so the clip starts and ends on the same picture, and `generate_audio` false, because the Canvas plays over your song. It tells you the price. For a price of 1 credit or more it waits for your yes. Then it calls `generate` with `max_credits` set to that price and an `idempotency_key`, and reads the result with `read`, never with a second `generate`. Expected result: a sequence URL for a short vertical clip that loops.
</Step>
<Step title="Fit the Canvas to the spec">
The agent makes a 9:16 timeline with `new_timeline` and places the clip with `insert_media`. If the clip is short, it repeats it with `media` and `video.loop` on the clip's sequence URL, with `target_duration`, after a dry run. It runs `capture` on the last frame and the first frame to check the seam. Expected result: a vertical loop with no jump at the seam.
</Step>
<Step title="Export the Canvas">
The agent runs `export` with `format` video and `quality` 1080. Expected result: an MP4 you upload in Spotify for Artists.
</Step>
</Steps>

### Make the visualizer

The visualizer is a full-length video that pulses with the song.

<Steps>
<Step title="Read the song">
The agent runs `media` with `audio.beats` on the song for the strong and weak beats, and `audio.waveform` for the level over time. For the drops and phrases, it reads `audio.markers`. Expected result: lists of beat times and level peaks, in seconds.
</Step>
<Step title="Build the visualizer timeline">
The agent makes a 16:9 timeline and a branch with `timeline_history`. It places the song on an audio track, and the art or a generated background on `V1`. It adds a circle with `add_shape` and preset `ellipse` on `V2`. Expected result: the song, the background and the shape on the timeline.
</Step>
<Step title="Pulse the shape on the beat">
The agent adds keyframe lanes for `zoom_x` and `zoom_y` on the shape with `add_keyframe_lane`. Then it adds a point on each beat with `add_keyframes`: bigger on the beat, back to normal a moment after. It can size each pulse by the `audio.waveform` peak at that time. Expected result: the circle grows on every beat and shrinks between them.
</Step>
<Step title="Add effects">
The agent adds effects from the catalog with `add_effect`, for example `beat_rock` for color bursts, `aurora` for moving light, or `kaleidoscope` on the background. In a later call, with the effect ids from `get_timeline`, it sets `beat_rock`'s `interval` or `kaleidoscope`'s `speed` with `set_effect_parameter` to match the tempo from `audio.markers`. Expected result: the effects move with the song.
</Step>
<Step title="Check the pulse before export">
The agent runs `capture` on a few beats and between them, and `check_timeline`. Expected result: the pulse lands on the beat, not after it.
</Step>
<Step title="Export the visualizer">
The agent runs `export` with `format` video and reads the export card. Expected result: an export card with status `completed`.
</Step>
</Steps>

The same job from a terminal:

```bash
invideo project new --title "Night Drive visuals"
invideo agent register <project-url> --name <name>
export INVIDEO_AS=<name>
invideo edit new <project-url> --name "Visualizer" --width 1920 --height 1080
invideo asset upload <edit-url> ./night-drive.wav ./cover.png
invideo media image.resizeAspectRatio <sequence-url> --aspect-ratio 9:16 \
  --dry-run
invideo media image.resizeAspectRatio <sequence-url> --aspect-ratio 9:16 \
  --max-credits <price> --idempotency-key cover-9x16
invideo gen models <project-url> --query "looping video from an image"
invideo gen model <project-url> --model seedance_2_5.frames
invideo gen run <page-url> --model seedance_2_5.frames --dry-run \
  -i prompt="slow neon light drifting over the cover, seamless loop" \
  -i first_frame=<cover-9x16-url> -i last_frame=<cover-9x16-url> \
  -i generate_audio=false
invideo gen run <page-url> --model seedance_2_5.frames \
  --max-credits <price> --idempotency-key canvas-1 \
  -i prompt="slow neon light drifting over the cover, seamless loop" \
  -i first_frame=<cover-9x16-url> -i last_frame=<cover-9x16-url> \
  -i generate_audio=false
invideo media audio.beats <edit-url> --asset m1
invideo media audio.waveform <edit-url> --asset m1
invideo edit show <edit-url> --json
invideo branch new <edit-url> --name visualizer --token <state_token>
invideo edit show <branch-url> --json
invideo edit change <branch-url> --timeline T1 \
  --token <state_token> --operations @ops.json
invideo edit capture <branch-url> --at 1.5,1.62,3.0 --out ./frames
invideo edit check <branch-url>
invideo edit export <branch-url> --format video --quality 1080 --json
invideo status <card-url> --wait
invideo notebook show <card-url> --out ./out
```

Where each value comes from:

- `<name>` is the name you choose in `invideo agent register`. `INVIDEO_AS` passes it to every later command.
- `<project-url>` comes from `invideo project new`, and `<edit-url>` from `invideo edit new`.
- `<sequence-url>` is the cover's `url` that `invideo asset upload` returns, a `…/notebook/seq-N.M` URL. `image.resizeAspectRatio` refuses a bin `--asset`. `m1` is the song's bin id from the same upload. Reads like `audio.beats` take `--asset m1`.
- `<cover-9x16-url>` is the sequence URL that `image.resizeAspectRatio` returns.
- `<page-url>` is a notebook page of the project, listed by `invideo notebook show <project-url>`. `<price>` is each dry run's `.estimated_credits`.
- `<state_token>` comes from `invideo edit show`: on `<edit-url>` for the branch, then on `<branch-url>`, read again before each change. `<branch-url>` is the `.branch_url` that `invideo branch new` returns.
- `@ops.json` reads a bare JSON list of ops, like the `operations` list of the first call below. Each later call is its own `edit change`.
- `<card-url>` is `.sequences[0]` of the export result. `invideo status` waits but saves nothing. `invideo notebook show` saves the video as `./out/export.mp4`, and the agent renames it.

The key MCP calls make the pulse. The first, shown as the full call, adds the shape. `<branch-url>` is the branch URL `timeline_history` returns, and `<state_token>` comes from `get_timeline` on it. At 30 fps, a song of 180 seconds is 5400 frames.

```json
{
  "url": "<branch-url>",
  "timeline_id": "T1",
  "expected_state_token": "<state_token>",
  "operations": [
    { "op": "add_shape", "track": "V2", "at_f": 0, "duration_f": 5400,
      "preset": "ellipse", "width": 400, "height": 400, "x": 0, "y": 0,
      "fill": "#7cf6ff" }
  ]
}
```

The lanes come next, shown as the bare op list that `--operations @ops.json` takes. They can also go later in the first call, since a clip id made earlier in a call can be used later in it. `<shape-clip-id>` is the shape clip's id, read with `get_timeline` after the first call.

```json
[
  { "op": "add_keyframe_lane", "parent": "<shape-clip-id>",
    "parent_kind": "video_clip", "property": "zoom_x",
    "default_value": 1 },
  { "op": "add_keyframe_lane", "parent": "<shape-clip-id>",
    "parent_kind": "video_clip", "property": "zoom_y",
    "default_value": 1 }
]
```

The points need the lane ids, so they go in their own call, shown as a bare op list. `<zoom-x-lane-id>` is the `zoom_x` lane's id, read with `get_timeline` after the lanes land. `time_seconds` counts from the clip's start, and the shape starts at 0, so the beat times go in as they are.

```json
[
  { "op": "add_keyframes", "lane": "<zoom-x-lane-id>",
    "points": [ { "time_seconds": 1.5, "value": 1.3 },
                { "time_seconds": 1.62, "value": 1 },
                { "time_seconds": 2.0, "value": 1.3 },
                { "time_seconds": 2.12, "value": 1 } ] }
]
```

The agent sends the same points to the `zoom_y` lane, so the circle keeps its shape.

## Cost and time

Edits and exports are free. Generation and transcripts cost credits. Paid stock costs a small credit charge, or one of your workspace's iStock licenses. Your agent runs a dry run first and tells you the price before it spends anything.

- **Costs credits:** the generated Canvas loop or background, and any paid stock clip.
- **Tool ops:** `image.resizeAspectRatio`, `video.frames`, `video.loop`, `audio.beats`, `audio.waveform`, `audio.markers`. The dry run shows its price before anything starts.
- **Free:** the shapes, keyframes, effects and every export.

The Canvas takes a few minutes, most of it the generation. The visualizer takes the agent a few minutes more. See [Credits and what they cost](/account/credits) for prices.

## If something goes wrong

Most problems come from a loop that jumps, or a pulse that lands late.

| Symptom | Fix |
| --- | --- |
| `get_model` or `generate` refuses the model as not found | Models retire. Run `find_models` with the job in words, use the best match, and tell the person which model you used. |
| Exit code 6, or `status` is `held` | A safety review, not a refusal. Wait, then run `invideo status <url> --wait` again. Do not resend. If it ends `failed`, change the input the review flagged. |
| `invideo status --wait` ends and the folder is empty | `status` does not download. Run `invideo notebook show <card-url> --out <dir>`. |
| `image.resizeAspectRatio` or `video.loop` refused with `code: "not_found"` | The call named a media bin asset. Pass the file's sequence URL from `add_media` or `invideo asset upload`. |
| The Canvas jumps at the loop | Generate again with the same image as `first_frame` and `last_frame`, or crossfade a copy with `duplicate_clip` and `add_transition`. |
| The pulse feels late | Move each point a frame or two earlier, so the peak lands on the beat. |
| Too many pulses | Use only the strong beats from `audio.beats`, or every second beat. |
| `audio.beats` finds few beats | The track has a soft beat. Use `audio.onsets` for hits, or `audio.markers` for tempo. |
| An effect hides the art | Lower its strength with `set_effect_parameter`, or put it on the background only. |
| CLI exit 3 | The timeline changed since your read. Run `invideo edit show` and rebuild. |
| Avoid: one `set_keyframes` call for the zoom | Instead: `set_keyframes` takes opacity and rotation only. Use `add_keyframe_lane` and `add_keyframes` for zoom. |

## Prompts to try

Each prompt is a real variation of the job.

```text
Make a Spotify Canvas from my live clip ./stage.mp4: the best few
seconds, slowed down, vertical, looping with no jump.
```

```text
Make a visualizer for my lo-fi track: rain on a window as the
background, a soft white bar that grows with the level, and a gentle
aurora effect.
```

```text
Make a 1:1 audio visualizer for Instagram with my cover in the middle,
spinning slowly with set_keyframes on rotation, and flashing on the
drop.
```

## When to use something else

invideo does not upload to Spotify. Upload the Canvas in Spotify for Artists. invideo has no live audio-reactive effect that listens to the sound by itself. The agent reads the beats and writes the keyframes, so a new mix needs a new read.

For a lyric video, see the lyric video page below.

## Related

- [Make a lyric video or text animation for your song](/how-to/creators/lyric-video)
- [Make a quote video, nature loop or welcome video](/how-to/creators/quote-and-nature-loop-videos)
- [Repurpose one video for every platform](/how-to/creators/repurpose-for-every-platform)
- [Timeline edit operations](/reference/edit-operations)
- [Check your video before you say it is done](/how-to/check-your-video)