# JSON to video API, one timeline to MP4

Canonical: https://rendobar.com/blog/json-to-video-api/
Author: Abdelrahman Essawy
Published: 2026-09-27
Updated: 2026-10-20

---

## Key takeaways

- A JSON timeline describes the edit, not the arithmetic. Leave out start times and the engine places each clip after the one before it, pulled back by any transition between them.
- The equivalent FFmpeg filter graph works, but every offset in it is computed by hand, so changing one clip's length means editing the numbers that follow it.
- Validate before you render. A timeline check costs nothing and catches the mistakes that would otherwise fail a paid render.
- Template-editor APIs suit teams where a designer owns the template and data fills it. A timeline API with raw FFmpeg on the same key suits developers and agents who write the edit themselves.

A JSON to video API takes a timeline, meaning tracks of clips, transitions between them and text over them, and returns a rendered file. The timeline below turns three stock clips, a title, a lower-third, an end card and a music bed into an 18.1 second 1080p MP4. It rendered in 14.6 seconds for $0.0141 on 2026-09-27, and this post shows the full JSON, frames from the output and the FFmpeg filter graph that produces the same edit by hand.

Corrected on 2026-10-18: compose now renders on a new engine and bills per second of the video it returns, $0.04 a minute on every plan, with a 15-second minimum per render. It used to bill render time. At today's price this 18.1 second video costs $0.013. The render time and cost above are the original run.

## The timeline that was rendered

This is the exact request body of `job_0079c48d4c304499`. Three clips sit on a picture track with a crossfade and a dip to black, and four more tracks carry the text and the music.

```json
{
  "schemaVersion": 1,
  "output": { "format": "mp4", "size": "1080p", "fps": 30 },
  "timeline": {
    "tracks": [
      {
        "name": "picture",
        "clips": [
          { "asset": { "type": "video", "src": "https://cdn.rendobar.com/compose/showcase/media/road.mp4", "volume": 0 } },
          { "type": "transition", "transition": "crossfade", "duration": 0.8 },
          { "asset": { "type": "video", "src": "https://cdn.rendobar.com/compose/showcase/media/river.mp4", "volume": 0 } },
          { "asset": { "type": "video", "src": "https://cdn.rendobar.com/compose/showcase/media/night.mp4", "volume": 0 }, "length": 4 },
          { "type": "transition", "transition": "fade", "duration": 0.6 },
          { "asset": { "type": "shape", "color": ["#064E3B", "#022C22"] }, "length": 3.5 }
        ]
      },
      {
        "name": "title",
        "clips": [
          {
            "start": 0.3,
            "length": 2.6,
            "asset": {
              "type": "text",
              "text": "TAKE THE LONG WAY",
              "style": { "font": "Bebas Neue", "size": 150, "color": "#FFFFFF", "align": "center",
                         "shadow": { "opacity": 0.7, "offsetX": 3, "offsetY": 3 },
                         "animate": { "type": "slideUp", "duration": 0.7 } }
            },
            "transform": { "position": { "x": "50%", "y": "45%" } }
          }
        ]
      },
      {
        "name": "lower-third",
        "clips": [
          {
            "start": 6.4,
            "length": 3.6,
            "asset": { "type": "shape", "color": "#0A0A0ACC" },
            "transform": { "anchor": "topLeft", "position": { "x": "5%", "y": "78%" }, "size": { "width": "30%", "height": "11%" } }
          }
        ]
      },
      {
        "name": "lower-third-text",
        "clips": [
          {
            "start": 6.4,
            "length": 3.6,
            "asset": {
              "type": "text",
              "text": "Day two, down the river",
              "style": { "font": "Inter", "size": 44, "weight": 600, "color": "#FFFFFF", "align": "left",
                         "animate": { "type": "slideLeft", "duration": 0.5 } }
            },
            "transform": { "position": { "x": "7%", "y": "83.5%" } }
          }
        ]
      },
      {
        "name": "end-card",
        "clips": [
          {
            "start": 15,
            "asset": {
              "type": "text",
              "text": "Book your weekend",
              "style": { "font": "Bebas Neue", "size": 130, "color": "#FFFFFF", "align": "center",
                         "animate": { "type": "fade", "duration": 0.6 } }
            },
            "transform": { "position": { "x": "50%", "y": "50%" } }
          }
        ]
      },
      {
        "name": "music",
        "clips": [
          {
            "length": "end",
            "asset": { "type": "audio", "src": "https://api.rendobar.com/assets/asset_eb9db1fa80334c5f/content",
                       "volume": 0.45, "fadeIn": 1, "fadeOut": 3 }
          }
        ]
      }
    ]
  }
}
```

The three clips are 6 second cuts of public domain and CC0 footage that Rendobar hosts for its effect reference, credited in the sources below. The music is a CC0 track I uploaded once as an asset, so its `src` is an asset URL that only resolves for my account. Point it at any audio URL of your own.

_The crossfade at 5.6 s, halfway through the 0.8 s overlap between road.mp4 and river.mp4. Frame from job_0079c48d4c304499._

_The lower-third at 8 s: a shape clip at 80 percent opacity with a text clip on the track above it. Frame from job_0079c48d4c304499._

## How a timeline is put together

Four ideas cover almost every timeline.

- Tracks stack. A later track draws over an earlier one, so the picture track is at the bottom and the text tracks sit on top of it.
- Clips hold one asset each: a video, image, audio file, text, shape or a nested composition. A clip with no `start` follows whatever is already on its track. A clip with no `length` runs as long as its content, and text and shapes have no length of their own, so they stay up until their container ends. `"length": "end"` stretches a clip to the end of the video, which is what a music bed needs.
- Transitions are items placed between two clips. The engine overlaps the neighbours by the transition's duration, so nothing downstream needs recalculating.
- Keyframes animate any numeric field over time: `"scale": [{ "time": "0%", "value": 1 }, { "time": "100%", "value": 1.12 }]` is a slow push-in, and a list of keyframes on `volume` ducks music under speech. The `animate` presets on the text clips above are shorthand for the common entrances.

Laid out on a time axis, the request above looks like this. I gave the text clips their start times. The engine placed everything on the picture and music tracks.

<figure class="diagram">
<svg viewBox="0 0 740 300" role="img" aria-label="The rendered timeline on a time axis from 0 to 18.1 seconds. The picture track holds road, river and night clips and an end card, with a crossfade between road and river and a fade between night and the end card. Title, lower-third and end-card text tracks sit above it, and a music track with fades runs the whole length." xmlns="http://www.w3.org/2000/svg" font-family="inherit">
<text x="12" y="59" font-size="13" fill="currentColor" fill-opacity="0.75">picture</text>
<line x1="132" y1="54" x2="704" y2="54" stroke="currentColor" stroke-opacity="0.08" />
<text x="12" y="107" font-size="13" fill="currentColor" fill-opacity="0.75">title</text>
<line x1="132" y1="102" x2="704" y2="102" stroke="currentColor" stroke-opacity="0.08" />
<text x="12" y="147" font-size="13" fill="currentColor" fill-opacity="0.75">lower-third ×2</text>
<line x1="132" y1="142" x2="704" y2="142" stroke="currentColor" stroke-opacity="0.08" />
<text x="12" y="187" font-size="13" fill="currentColor" fill-opacity="0.75">end-card</text>
<line x1="132" y1="182" x2="704" y2="182" stroke="currentColor" stroke-opacity="0.08" />
<text x="12" y="227" font-size="13" fill="currentColor" fill-opacity="0.75">music</text>
<line x1="132" y1="222" x2="704" y2="222" stroke="currentColor" stroke-opacity="0.08" />
<rect x="132" y="40" width="189.6" height="28" rx="6" fill="currentColor" fill-opacity="0.07" stroke="currentColor" stroke-opacity="0.25" />
<text x="226.8" y="58" font-size="12" text-anchor="middle" fill="currentColor">road.mp4</text>
<rect x="296.3" y="40" width="189.6" height="28" rx="6" fill="currentColor" fill-opacity="0.07" stroke="currentColor" stroke-opacity="0.25" />
<text x="391.1" y="58" font-size="12" text-anchor="middle" fill="currentColor">river.mp4</text>
<rect x="485.9" y="40" width="126.5" height="28" rx="6" fill="currentColor" fill-opacity="0.07" stroke="currentColor" stroke-opacity="0.25" />
<text x="549.1" y="58" font-size="12" text-anchor="middle" fill="currentColor">night.mp4</text>
<rect x="593.4" y="40" width="110.6" height="28" rx="6" fill="currentColor" fill-opacity="0.07" stroke="currentColor" stroke-opacity="0.25" />
<text x="648.7" y="58" font-size="12" text-anchor="middle" fill="currentColor">card</text>
<rect x="296.3" y="36" width="25.3" height="36" rx="3" fill="#059669" fill-opacity="0.35" />
<text x="309.0" y="30" font-size="12" text-anchor="middle" fill="#059669">crossfade 0.8</text>
<rect x="593.4" y="36" width="19.0" height="36" rx="3" fill="#059669" fill-opacity="0.35" />
<text x="602.9" y="30" font-size="12" text-anchor="middle" fill="#059669">fade 0.6</text>
<rect x="141.5" y="88" width="82.1" height="28" rx="6" fill="#059669" fill-opacity="0.18" stroke="#059669" stroke-opacity="0.6" />
<text x="182.6" y="106" font-size="12" text-anchor="middle" fill="currentColor">title</text>
<rect x="334.3" y="128" width="113.7" height="28" rx="6" fill="#059669" fill-opacity="0.18" stroke="#059669" stroke-opacity="0.6" />
<text x="391.1" y="146" font-size="12" text-anchor="middle" fill="currentColor">Day two…</text>
<rect x="606" y="168" width="98.0" height="28" rx="6" fill="#059669" fill-opacity="0.18" stroke="#059669" stroke-opacity="0.6" />
<text x="655.0" y="186" font-size="12" text-anchor="middle" fill="currentColor">Book…</text>
<polygon points="132,236 163.6,212 609.2,212 704,236" fill="currentColor" fill-opacity="0.07" stroke="currentColor" stroke-opacity="0.25" />
<text x="384.8" y="229" font-size="12" text-anchor="middle" fill="currentColor">sweet-sun.m4a, length "end", fadeIn 1, fadeOut 3</text>
<line x1="132" y1="256" x2="704" y2="256" stroke="currentColor" stroke-opacity="0.4" />
<line x1="132" y1="252" x2="132" y2="260" stroke="currentColor" stroke-opacity="0.4" />
<text x="132" y="278" font-size="12" text-anchor="middle" fill="currentColor" fill-opacity="0.75">0 s</text>
<line x1="290" y1="252" x2="290" y2="260" stroke="currentColor" stroke-opacity="0.4" />
<text x="290" y="278" font-size="12" text-anchor="middle" fill="currentColor" fill-opacity="0.75">5 s</text>
<line x1="448" y1="252" x2="448" y2="260" stroke="currentColor" stroke-opacity="0.4" />
<text x="448" y="278" font-size="12" text-anchor="middle" fill="currentColor" fill-opacity="0.75">10 s</text>
<line x1="606" y1="252" x2="606" y2="260" stroke="currentColor" stroke-opacity="0.4" />
<text x="606" y="278" font-size="12" text-anchor="middle" fill="currentColor" fill-opacity="0.75">15 s</text>
<line x1="704" y1="252" x2="704" y2="260" stroke="currentColor" stroke-opacity="0.4" />
<text x="704" y="278" font-size="12" text-anchor="middle" fill="currentColor" fill-opacity="0.75">18.1 s</text>
</svg>
<figcaption>The rendered timeline. Emerald bands are transitions, where the neighbouring clips overlap.</figcaption>
</figure>

Nothing in the JSON says the river clip starts at 5.2 seconds or that the video ends at 18.1. Those fall out of the clip lengths and the transition durations. That is the whole difference between describing an edit and scripting one.

## The same edit as an FFmpeg filter graph

FFmpeg can produce this video, and I checked by building it on FFmpeg 8.0 from the same three clips, the same music and the Bebas Neue and Inter font files. Its frames line up with the API render at the same timestamps, with the same cuts and the same text positions. This is the full `-filter_complex` script:

```text
[0:v]scale=1920:1080,fps=30,format=yuv420p,settb=AVTB,setpts=PTS-STARTPTS[road];
[1:v]scale=1920:1080,fps=30,format=yuv420p,settb=AVTB,setpts=PTS-STARTPTS[river];
[2:v]trim=duration=4,scale=1920:1080,fps=30,format=yuv420p,settb=AVTB,setpts=PTS-STARTPTS[night];
gradients=s=1920x1080:r=30:c0=0x064E3B:c1=0x022C22:x0=960:y0=0:x1=960:y1=1080:speed=0:d=3.5,format=yuv420p,settb=AVTB[card];
[road][river]xfade=transition=fade:duration=0.8:offset=5.2[a];
[a][night]concat=n=2:v=1:a=0[b];
[b][card]xfade=transition=fadeblack:duration=0.6:offset=14.6[c];
[c]drawtext=fontfile=BebasNeue-Regular.ttf:text='TAKE THE LONG WAY':fontsize=150:fontcolor=white:shadowcolor=black@0.7:shadowx=3:shadowy=3:x=(w-tw)/2:y=486-th/2+60*(1-min(1\,(t-0.3)/0.7)):alpha='min(1,(t-0.3)/0.7)':enable='between(t,0.3,2.9)',
drawbox=x=96:y=842:w=576:h=119:color=0x0A0A0A@0.8:t=fill:enable='between(t,6.4,10)',
drawtext=fontfile=Inter.ttf:text='Day two, down the river':fontsize=44:fontcolor=white:x=134+80*(1-min(1\,(t-6.4)/0.5)):y=902-th/2:alpha='min(1,(t-6.4)/0.5)':enable='between(t,6.4,10)',
drawtext=fontfile=BebasNeue-Regular.ttf:text='Book your weekend':fontsize=130:fontcolor=white:x=(w-tw)/2:y=(h-th)/2:alpha='min(1,(t-15)/0.6)':enable='gte(t,15)'[v];
[3:a]atrim=duration=18.1,afade=t=in:d=1,afade=t=out:st=15.1:d=3,volume=0.45[aud]
```

It works, and it is where the cost of the DIY route shows.

- Every offset is arithmetic you own. `offset=5.2` is the road clip's 6 seconds minus the 0.8 second crossfade. `offset=14.6` is 5.2 plus 6 plus 4 minus 0.6. The music fade-out starts at 15.1 because the video ends at 18.1. Trim the road clip by a second and four numbers change.
- Every input has to be normalised first. `xfade` wants both inputs at the same frame size, frame rate, pixel format and timebase, which is why each branch repeats the same `scale`, `fps`, `format` and `settb` chain.
- Animation is a per-frame expression. The title's slide-up is `y=486-th/2+60*(1-min(1,(t-0.3)/0.7))`, and 486 is 45 percent of 1080 worked out by hand. Change the output to 720p and every pixel coordinate is wrong.
- Fonts are files on disk. `drawtext` needs a path to each font. It also cannot pick a weight from a variable font, so my local Inter rendered at its default 400 where the timeline asked for 600.

If you already maintain a graph like this and your edits never change shape, keep it. A pinned filter graph run through Rendobar's [FFmpeg API](/ffmpeg/) or your own workers is a fine way to stamp out one fixed layout. The moment the edit is data-driven, with clip counts, lengths or text that vary per video, the timeline stops being a convenience and starts saving the bug you would otherwise write in the offset maths.

## Validate first, then draft, then render

Rendering is the only step that costs anything, so the workflow puts two cheap steps in front of it.

`validate_compose` runs the same checks a submit runs, plus the timeline checks a render would otherwise fail on, such as a transition that does not sit between two clips. It returns the timeline with every default filled in and renders nothing. This timeline passed with no warnings on the first check.

A draft render is the second step. Setting `"output": { "quality": "draft", "resolution": { "width": 640, "height": 360 }, "fps": 30 }` and leaving the timeline alone produced `job_43b4e6ee57ec467f`, which took 7.5 seconds and cost $0.0069 on 2026-09-27. It is good for checking timing and cuts. It is misleading for text, because text sizes are pixels at the output resolution, so a 150 px title fills far more of a 360-line frame than of a 1080-line one.

Corrected on 2026-10-18: a draft no longer costs less than the final render. Compose now bills by the length of the video at one rate whatever the frame size, so this draft and the final 1080p render both cost $0.013. The draft was cheaper when the price followed render time.

_The same frame at 1.2 s from the draft (left, job_43b4e6ee57ec467f) and the final render (right, job_0079c48d4c304499). Text sizes are output pixels, so the draft exaggerates them._

Judge layout and type on the final resolution. Use the draft for pacing only.

## Render it with the API

Save the JSON above as `timeline.json` and submit it as the `params` of a `compose` job:

const rb = createClient({ apiKey: process.env.RENDOBAR_API_KEY });

// The timeline from this post. Replace the music src with your own audio URL.
const params = JSON.parse(await readFile("timeline.json", "utf8"));

const job = await rb.jobs.run({ type: "compose", inputs: {}, params });

console.log(job.output.file.url);`}
  curl={`curl -X POST https://api.rendobar.com/jobs \\
  -H "Authorization: Bearer $RENDOBAR_API_KEY" \\
  -H "Content-Type: application/json" \\
  -d "{\\"type\\": \\"compose\\", \\"inputs\\": {}, \\"params\\": $(cat timeline.json)}"`}
/>

The final render wrote a 10.2 MB H.264 file with AAC audio. Cost follows the length of the video, so a longer video costs proportionally more, whatever its frame size. The same timeline bills the same amount on every run, while the render time moves a little between runs. (Corrected on 2026-10-18: this paragraph said cost follows render time, which was how compose billed when the post was written.)

## Template-editor APIs

Shotstack and Creatomate are the two JSON video APIs most people compare, and both are built around templates.

Shotstack renders a JSON timeline too, and adds a white-label editor SDK you can embed in your own product. Its pricing on 2026-09-27 was $0.20 per minute of rendered output on a $39 monthly subscription, or $0.30 per minute pay-as-you-go after a $75 one-time base. At the subscription rate this 18 second clip would come to $0.06, before the monthly fee. Rendobar's compose bills the same clip $0.013 with no monthly fee. [Rendobar vs Shotstack](/vs/shotstack/) has the full comparison.

Corrected on 2026-10-20: Shotstack's pricing page now lists pay as you go at $10 for 50 minutes, 20 cents a minute with no base fee, and Starter at $39 a month for 250 minutes, 15.6 cents a minute. Shotstack counts each render by the second, rounded down, so this clip comes to $0.06 on pay as you go and about $0.05 on Starter. The figures in the paragraph above were read on 2026-09-27. The plan-by-plan prices and a migration guide are in [Shotstack alternative, the cheapest JSON video API](/blog/shotstack-alternative/).

Creatomate centres on a visual template editor, where a designer builds the video and the API fills in text, images and clips through modifications. It bills in credits of roughly 100 million rendered pixels each, with the Essential plan at $54 a month for 2,000 credits that reset monthly (checked 2026-09-27). By that measure this clip is about 11 credits, around $0.30 of an Essential plan, but Creatomate's own calculator decides the real number. The details are in [Rendobar vs Creatomate](/vs/creatomate/).

Corrected on 2026-10-20: Creatomate rounds credits up per render, so at 1080p and 30 fps this 18.1 second clip is 12 credits, about $0.32 of an Essential plan, not 11. [Creatomate's credits converted to minutes](/blog/creatomate-alternative/) shows the per-minute cost on each plan.

If a designer or marketer owns the look and your code only swaps the words and pictures, Creatomate's editor or Shotstack's embeddable one is the better fit, and I would pick one of them. Neither takes an FFmpeg command, so anything the template cannot express needs a second service.

JSON2Video is the third name that comes up. It describes a video as scenes played one after another, a shape compose also accepts, and it adds text-to-speech voices inside the movie. [Moving from JSON2Video's scenes to compose](/blog/json2video-alternative/) maps one format onto the other, and [JSON video API pricing at three monthly volumes](/blog/json-video-api-pricing-compared/) puts all of these on one table.

Rendobar is the one I recommend when developers or agents write the edit. The `compose` timeline and the raw `ffmpeg` job run on the same API key and the same credits, so a timeline render and a one-off filter graph never need two vendors. A timeline bills per second of the video it returns, an `ffmpeg` job per second of compute, neither has a base fee on pay-as-you-go, and credits never expire. (Corrected on 2026-10-18: this said timelines billed per second of render time.) The hosted MCP server exposes `validate_compose` and `submit_job`, so an agent in Claude or ChatGPT can write a timeline, check it for free and render it, which is how this post's jobs were run.

Fonts are where timelines most often go quietly wrong, and [custom fonts in a video API fail silently](/blog/custom-fonts-video-api/) covers the fallback stack that prevents it. If you are choosing a provider more broadly, [the best FFmpeg API](/blog/best-ffmpeg-api/) compares the command-first services.

I would write new video features as timelines from the start. Keep a hand-built filter graph for the one fixed layout it already serves, and move anything that varies per video into JSON, where changing a clip is one edit and not a recalculation.
