{
  "openapi": "3.1.0",
  "info": {
    "title": "vidhook API",
    "version": "0.1.0",
    "description": "Programmatic video generation API. Send a Movie definition, receive an MP4.\n\nFor help authoring valid Movie JSON, see the Movie JSON guide and MCP/skill at [vidhook/vidhook-mcp](https://github.com/vidhook/vidhook-mcp)."
  },
  "externalDocs": {
    "url": "https://docs.vidhook.app/",
    "description": "vidhook documentation."
  },
  "tags": [
    {
      "name": "Renders",
      "description": "Submit, validate, and poll video renders."
    }
  ],
  "servers": [
    {
      "url": "https://api.vidhook.app"
    }
  ],
  "components": {
    "securitySchemes": {
      "apiKey": {
        "type": "http",
        "scheme": "bearer",
        "description": "vidhook API key. Sent as Authorization: Bearer vh_test_<...> (free/watermark) or vh_live_<...> (paid/clean)."
      }
    },
    "schemas": {
      "Movie": {
        "type": "object",
        "properties": {
          "resolution": {
            "$ref": "#/components/schemas/Resolution"
          },
          "width": {
            "type": "integer",
            "minimum": 50,
            "maximum": 3840,
            "description": "Output width in px (50..3840). Required when resolution=custom.",
            "example": 640
          },
          "height": {
            "type": "integer",
            "minimum": 50,
            "maximum": 3840,
            "description": "Output height in px (50..3840). Required when resolution=custom.",
            "example": 360
          },
          "fps": {
            "anyOf": [
              {
                "type": "number",
                "enum": [
                  24
                ]
              },
              {
                "type": "number",
                "enum": [
                  25
                ]
              },
              {
                "type": "number",
                "enum": [
                  30
                ]
              }
            ],
            "default": 30,
            "description": "Frames per second. Allowed values: 24, 25, 30 (default 30). A vidhook-specific field required for rendering.",
            "example": 30
          },
          "quality": {
            "$ref": "#/components/schemas/Quality"
          },
          "scenes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Scene"
            },
            "minItems": 1,
            "description": "Ordered scenes, at least one."
          },
          "elements": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Element"
            },
            "default": [],
            "description": "Movie-wide overlay elements, rendered across all scenes."
          }
        },
        "required": [
          "scenes"
        ]
      },
      "Resolution": {
        "type": "string",
        "enum": [
          "sd",
          "hd",
          "full-hd",
          "squared",
          "instagram-story",
          "instagram-feed",
          "twitter-landscape",
          "twitter-portrait",
          "custom"
        ],
        "default": "custom",
        "description": "Output resolution preset. Use \"custom\" together with width/height for arbitrary sizes.",
        "example": "custom"
      },
      "Quality": {
        "type": "string",
        "enum": [
          "low",
          "medium",
          "high"
        ],
        "default": "high",
        "description": "Render quality hint. Accepted in MVP but encoding mapping is applied from #17 onward.",
        "example": "high"
      },
      "Scene": {
        "type": "object",
        "properties": {
          "duration": {
            "type": "number",
            "default": -1,
            "description": "Scene duration in seconds. -1 = auto: computed from the contained elements as the maximum placement end (start + resolved element duration); container (-2) elements are excluded from this computation. Falls back to a default length when no element can determine the scene length."
          },
          "background-color": {
            "anyOf": [
              {
                "type": "string",
                "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
              },
              {
                "type": "string",
                "enum": [
                  "transparent"
                ]
              }
            ],
            "default": "#000000",
            "description": "Hex color (#rgb / #rrggbb) or \"transparent\"."
          },
          "elements": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Element"
            },
            "default": []
          },
          "transition": {
            "$ref": "#/components/schemas/Transition"
          }
        }
      },
      "Element": {
        "oneOf": [
          {
            "$ref": "#/components/schemas/VideoElement"
          },
          {
            "$ref": "#/components/schemas/ImageElement"
          },
          {
            "$ref": "#/components/schemas/TextElement"
          },
          {
            "$ref": "#/components/schemas/AudioElement"
          }
        ],
        "discriminator": {
          "propertyName": "type",
          "mapping": {
            "video": "#/components/schemas/VideoElement",
            "image": "#/components/schemas/ImageElement",
            "text": "#/components/schemas/TextElement",
            "audio": "#/components/schemas/AudioElement"
          }
        }
      },
      "VideoElement": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "video"
            ]
          },
          "src": {
            "type": "string",
            "format": "uri",
            "description": "Video source URL."
          },
          "seek": {
            "type": "number",
            "minimum": 0,
            "default": 0,
            "description": "In-source trim start in seconds (maps to trimBefore)."
          },
          "volume": {
            "type": "number",
            "minimum": 0,
            "maximum": 10,
            "default": 1,
            "description": "Volume gain, 0..10."
          },
          "muted": {
            "type": "boolean",
            "default": false,
            "description": "Mute the audio track."
          },
          "fit": {
            "type": "string",
            "enum": [
              "cover",
              "fill",
              "contain",
              "fit"
            ],
            "default": "cover",
            "description": "How the element fits its box. Accepts cover/fill/contain/fit; fill is treated as cover and fit as contain, normalized to cover/contain.",
            "example": "cover"
          },
          "start": {
            "type": "number",
            "default": 0,
            "description": "Start time in seconds."
          },
          "duration": {
            "type": "number",
            "default": -1,
            "description": "Duration in seconds. -1 = intrinsic: play the source natural length (video/audio), trimmed to the container if longer; image/text have no natural length and fall back to the container length. -2 = container: match the container (Scene, or Movie for movie-level elements), trimming the source if it is longer. The natural length is read from the source metadata."
          },
          "position": {
            "$ref": "#/components/schemas/Position"
          },
          "x": {
            "type": "number"
          },
          "y": {
            "type": "number"
          },
          "width": {
            "type": "number"
          },
          "height": {
            "type": "number"
          },
          "z-index": {
            "type": "integer",
            "minimum": -99,
            "maximum": 99,
            "default": 0,
            "description": "Stacking order, -99..99."
          },
          "effects": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Effect"
            },
            "default": [],
            "description": "Element animations. Named built-in effects (e.g. { \"use\": \"typewriter\" }) or inline effects. word/char targets are text-only (rejected on other element types)."
          }
        },
        "required": [
          "type",
          "src"
        ]
      },
      "Position": {
        "type": "string",
        "enum": [
          "top-left",
          "top-center",
          "top-right",
          "center-left",
          "center-center",
          "center-right",
          "bottom-left",
          "bottom-center",
          "bottom-right",
          "custom"
        ],
        "default": "custom",
        "description": "9-grid placement preset, or \"custom\" to position via x/y.",
        "example": "center-center"
      },
      "Effect": {
        "anyOf": [
          {
            "type": "object",
            "properties": {
              "use": {
                "type": "string",
                "enum": [
                  "fade",
                  "slide-fade",
                  "pop-in",
                  "blur-in",
                  "typewriter",
                  "word-reveal",
                  "pulse",
                  "shake"
                ],
                "description": "Built-in effect name."
              },
              "duration": {
                "type": "number",
                "exclusiveMinimum": 0
              },
              "delay": {
                "type": "number",
                "minimum": 0
              },
              "easing": {
                "$ref": "#/components/schemas/Easing"
              },
              "target": {
                "type": "string",
                "enum": [
                  "block",
                  "word",
                  "char"
                ]
              },
              "stagger": {
                "type": "number",
                "minimum": 0
              }
            },
            "required": [
              "use"
            ]
          },
          {
            "type": "object",
            "properties": {
              "animations": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Animation"
                },
                "minItems": 1
              }
            },
            "required": [
              "animations"
            ]
          }
        ],
        "description": "Either a named built-in effect ({ use, ...timing overrides }) or an inline effect ({ animations: [...] })."
      },
      "Easing": {
        "type": "string",
        "enum": [
          "linear",
          "ease-in",
          "ease-out",
          "ease-in-out"
        ],
        "description": "Easing curve name. Mapped to a Remotion easing at render time.",
        "example": "ease-out"
      },
      "Animation": {
        "oneOf": [
          {
            "type": "object",
            "properties": {
              "type": {
                "type": "string",
                "enum": [
                  "fade"
                ]
              },
              "role": {
                "type": "string",
                "enum": [
                  "in",
                  "out",
                  "loop"
                ],
                "default": "in",
                "description": "in = entrance, out = exit, loop = oscillate over the full length."
              },
              "duration": {
                "type": "number",
                "exclusiveMinimum": 0,
                "default": 0.5,
                "description": "Window length in seconds (for loop: one iteration / one-way leg)."
              },
              "easing": {
                "$ref": "#/components/schemas/Easing"
              },
              "delay": {
                "type": "number",
                "minimum": 0,
                "default": 0,
                "description": "Start delay in seconds."
              },
              "target": {
                "type": "string",
                "enum": [
                  "block",
                  "word",
                  "char"
                ],
                "default": "block",
                "description": "Animation granularity. block = the whole element; word/char = per unit (text only)."
              },
              "stagger": {
                "type": "number",
                "minimum": 0,
                "default": 0,
                "description": "Per-unit start offset in seconds (word/char targets)."
              },
              "iterations": {
                "type": "integer",
                "minimum": -1,
                "default": -1,
                "description": "Loop repeat count, -1 = infinite. Ignored for in/out."
              },
              "alternate": {
                "type": "boolean",
                "default": true,
                "description": "Loop back-and-forth around identity. Ignored for in/out."
              }
            },
            "required": [
              "type"
            ]
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "type": "string",
                "enum": [
                  "slide"
                ]
              },
              "direction": {
                "type": "string",
                "enum": [
                  "from-bottom",
                  "from-top",
                  "from-left",
                  "from-right"
                ],
                "default": "from-bottom",
                "description": "Entrance direction (loop uses only its axis)."
              },
              "distance": {
                "type": "number",
                "default": 48,
                "description": "Offset (in/out) or amplitude (loop) in px."
              },
              "role": {
                "type": "string",
                "enum": [
                  "in",
                  "out",
                  "loop"
                ],
                "default": "in",
                "description": "in = entrance, out = exit, loop = oscillate over the full length."
              },
              "duration": {
                "type": "number",
                "exclusiveMinimum": 0,
                "default": 0.5,
                "description": "Window length in seconds (for loop: one iteration / one-way leg)."
              },
              "easing": {
                "$ref": "#/components/schemas/Easing"
              },
              "delay": {
                "type": "number",
                "minimum": 0,
                "default": 0,
                "description": "Start delay in seconds."
              },
              "target": {
                "type": "string",
                "enum": [
                  "block",
                  "word",
                  "char"
                ],
                "default": "block",
                "description": "Animation granularity. block = the whole element; word/char = per unit (text only)."
              },
              "stagger": {
                "type": "number",
                "minimum": 0,
                "default": 0,
                "description": "Per-unit start offset in seconds (word/char targets)."
              },
              "iterations": {
                "type": "integer",
                "minimum": -1,
                "default": -1,
                "description": "Loop repeat count, -1 = infinite. Ignored for in/out."
              },
              "alternate": {
                "type": "boolean",
                "default": true,
                "description": "Loop back-and-forth around identity. Ignored for in/out."
              }
            },
            "required": [
              "type"
            ]
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "type": "string",
                "enum": [
                  "scale"
                ]
              },
              "from": {
                "type": "number",
                "default": 0.8,
                "description": "Start scale (loop: one extreme)."
              },
              "to": {
                "type": "number",
                "default": 1,
                "description": "End scale, identity=1 (loop: other extreme)."
              },
              "role": {
                "type": "string",
                "enum": [
                  "in",
                  "out",
                  "loop"
                ],
                "default": "in",
                "description": "in = entrance, out = exit, loop = oscillate over the full length."
              },
              "duration": {
                "type": "number",
                "exclusiveMinimum": 0,
                "default": 0.5,
                "description": "Window length in seconds (for loop: one iteration / one-way leg)."
              },
              "easing": {
                "$ref": "#/components/schemas/Easing"
              },
              "delay": {
                "type": "number",
                "minimum": 0,
                "default": 0,
                "description": "Start delay in seconds."
              },
              "target": {
                "type": "string",
                "enum": [
                  "block",
                  "word",
                  "char"
                ],
                "default": "block",
                "description": "Animation granularity. block = the whole element; word/char = per unit (text only)."
              },
              "stagger": {
                "type": "number",
                "minimum": 0,
                "default": 0,
                "description": "Per-unit start offset in seconds (word/char targets)."
              },
              "iterations": {
                "type": "integer",
                "minimum": -1,
                "default": -1,
                "description": "Loop repeat count, -1 = infinite. Ignored for in/out."
              },
              "alternate": {
                "type": "boolean",
                "default": true,
                "description": "Loop back-and-forth around identity. Ignored for in/out."
              }
            },
            "required": [
              "type"
            ]
          },
          {
            "type": "object",
            "properties": {
              "type": {
                "type": "string",
                "enum": [
                  "blur"
                ]
              },
              "from": {
                "type": "number",
                "minimum": 0,
                "default": 8,
                "description": "Start blur px (loop: one extreme)."
              },
              "to": {
                "type": "number",
                "minimum": 0,
                "default": 0,
                "description": "End blur px, identity=0."
              },
              "role": {
                "type": "string",
                "enum": [
                  "in",
                  "out",
                  "loop"
                ],
                "default": "in",
                "description": "in = entrance, out = exit, loop = oscillate over the full length."
              },
              "duration": {
                "type": "number",
                "exclusiveMinimum": 0,
                "default": 0.5,
                "description": "Window length in seconds (for loop: one iteration / one-way leg)."
              },
              "easing": {
                "$ref": "#/components/schemas/Easing"
              },
              "delay": {
                "type": "number",
                "minimum": 0,
                "default": 0,
                "description": "Start delay in seconds."
              },
              "target": {
                "type": "string",
                "enum": [
                  "block",
                  "word",
                  "char"
                ],
                "default": "block",
                "description": "Animation granularity. block = the whole element; word/char = per unit (text only)."
              },
              "stagger": {
                "type": "number",
                "minimum": 0,
                "default": 0,
                "description": "Per-unit start offset in seconds (word/char targets)."
              },
              "iterations": {
                "type": "integer",
                "minimum": -1,
                "default": -1,
                "description": "Loop repeat count, -1 = infinite. Ignored for in/out."
              },
              "alternate": {
                "type": "boolean",
                "default": true,
                "description": "Loop back-and-forth around identity. Ignored for in/out."
              }
            },
            "required": [
              "type"
            ]
          }
        ],
        "description": "A single property animation (fade/slide/scale/blur). Windows outside its range are identity; multiple animations compose per channel (gain=product, transform/filter=concat)."
      },
      "ImageElement": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "image"
            ]
          },
          "src": {
            "type": "string",
            "format": "uri",
            "description": "Image source URL."
          },
          "fit": {
            "type": "string",
            "enum": [
              "cover",
              "fill",
              "contain",
              "fit"
            ],
            "default": "cover",
            "description": "How the element fits its box. Accepts cover/fill/contain/fit; fill is treated as cover and fit as contain, normalized to cover/contain.",
            "example": "cover"
          },
          "zoom": {
            "type": "integer",
            "minimum": -10,
            "maximum": 10,
            "default": 0,
            "description": "Ken Burns zoom, -10..10. Positive zooms in, negative zooms out."
          },
          "pan": {
            "type": "string",
            "enum": [
              "left",
              "top",
              "right",
              "bottom",
              "top-left",
              "top-right",
              "bottom-left",
              "bottom-right"
            ],
            "description": "Ken Burns pan direction. Omit for no panning."
          },
          "pan-distance": {
            "type": "number",
            "minimum": 0.01,
            "maximum": 0.5,
            "default": 0.1,
            "description": "Ken Burns pan distance ratio, 0.01..0.5."
          },
          "start": {
            "type": "number",
            "default": 0,
            "description": "Start time in seconds."
          },
          "duration": {
            "type": "number",
            "default": -1,
            "description": "Duration in seconds. -1 = intrinsic: play the source natural length (video/audio), trimmed to the container if longer; image/text have no natural length and fall back to the container length. -2 = container: match the container (Scene, or Movie for movie-level elements), trimming the source if it is longer. The natural length is read from the source metadata."
          },
          "position": {
            "$ref": "#/components/schemas/Position"
          },
          "x": {
            "type": "number"
          },
          "y": {
            "type": "number"
          },
          "width": {
            "type": "number"
          },
          "height": {
            "type": "number"
          },
          "z-index": {
            "type": "integer",
            "minimum": -99,
            "maximum": 99,
            "default": 0,
            "description": "Stacking order, -99..99."
          },
          "effects": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Effect"
            },
            "default": [],
            "description": "Element animations. Named built-in effects (e.g. { \"use\": \"typewriter\" }) or inline effects. word/char targets are text-only (rejected on other element types)."
          }
        },
        "required": [
          "type",
          "src"
        ]
      },
      "TextElement": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "text"
            ]
          },
          "text": {
            "type": "string",
            "description": "Text content to render."
          },
          "settings": {
            "$ref": "#/components/schemas/TextSettings"
          },
          "start": {
            "type": "number",
            "default": 0,
            "description": "Start time in seconds."
          },
          "duration": {
            "type": "number",
            "default": -1,
            "description": "Duration in seconds. -1 = intrinsic: play the source natural length (video/audio), trimmed to the container if longer; image/text have no natural length and fall back to the container length. -2 = container: match the container (Scene, or Movie for movie-level elements), trimming the source if it is longer. The natural length is read from the source metadata."
          },
          "position": {
            "$ref": "#/components/schemas/Position"
          },
          "x": {
            "type": "number"
          },
          "y": {
            "type": "number"
          },
          "width": {
            "type": "number"
          },
          "height": {
            "type": "number"
          },
          "z-index": {
            "type": "integer",
            "minimum": -99,
            "maximum": 99,
            "default": 0,
            "description": "Stacking order, -99..99."
          },
          "effects": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Effect"
            },
            "default": [],
            "description": "Element animations. Named built-in effects (e.g. { \"use\": \"typewriter\" }) or inline effects. word/char targets are text-only (rejected on other element types)."
          }
        },
        "required": [
          "type",
          "text"
        ]
      },
      "TextSettings": {
        "type": "object",
        "properties": {
          "font-family": {
            "type": "string",
            "description": "Google Font family name. Falls back to sans-serif if unresolved."
          },
          "font-size": {
            "type": "number",
            "exclusiveMinimum": 0,
            "description": "Font size in px."
          },
          "font-weight": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ],
            "description": "CSS font-weight (\"bold\"/\"400\"/400). Normalized to a numeric weight."
          },
          "font-color": {
            "anyOf": [
              {
                "type": "string",
                "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
              },
              {
                "type": "string",
                "enum": [
                  "transparent"
                ]
              }
            ],
            "description": "Text color."
          },
          "background-color": {
            "anyOf": [
              {
                "type": "string",
                "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
              },
              {
                "type": "string",
                "enum": [
                  "transparent"
                ]
              }
            ],
            "description": "Text box background color."
          },
          "text-align": {
            "type": "string",
            "enum": [
              "left",
              "center",
              "right"
            ],
            "description": "Horizontal alignment of text within its box."
          },
          "vertical-position": {
            "type": "string",
            "enum": [
              "top",
              "center",
              "bottom"
            ],
            "description": "Vertical placement of the text inside the element box."
          },
          "horizontal-position": {
            "type": "string",
            "enum": [
              "left",
              "center",
              "right"
            ],
            "description": "Horizontal placement of the text inside the element box."
          }
        }
      },
      "AudioElement": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "audio"
            ]
          },
          "src": {
            "type": "string",
            "format": "uri",
            "description": "Audio source URL."
          },
          "volume": {
            "type": "number",
            "minimum": 0,
            "maximum": 10,
            "default": 1,
            "description": "Volume gain, 0..10."
          },
          "seek": {
            "type": "number",
            "minimum": 0,
            "default": 0,
            "description": "In-source trim start in seconds (maps to trimBefore)."
          },
          "loop": {
            "type": "integer",
            "minimum": -1,
            "default": 1,
            "description": "Play count. -1 loops indefinitely, 1 plays once (default)."
          },
          "muted": {
            "type": "boolean",
            "default": false,
            "description": "Mute the audio track."
          },
          "start": {
            "type": "number",
            "default": 0,
            "description": "Start time in seconds."
          },
          "duration": {
            "type": "number",
            "default": -1,
            "description": "Duration in seconds. -1 = intrinsic: play the source natural length (video/audio), trimmed to the container if longer; image/text have no natural length and fall back to the container length. -2 = container: match the container (Scene, or Movie for movie-level elements), trimming the source if it is longer. The natural length is read from the source metadata."
          },
          "position": {
            "$ref": "#/components/schemas/Position"
          },
          "x": {
            "type": "number"
          },
          "y": {
            "type": "number"
          },
          "width": {
            "type": "number"
          },
          "height": {
            "type": "number"
          },
          "z-index": {
            "type": "integer",
            "minimum": -99,
            "maximum": 99,
            "default": 0,
            "description": "Stacking order, -99..99."
          },
          "effects": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Effect"
            },
            "default": [],
            "description": "Element animations. Named built-in effects (e.g. { \"use\": \"typewriter\" }) or inline effects. word/char targets are text-only (rejected on other element types)."
          }
        },
        "required": [
          "type",
          "src"
        ]
      },
      "Transition": {
        "type": "object",
        "properties": {
          "style": {
            "type": "string",
            "default": "fade",
            "description": "ffmpeg xfade transition style name. Unknown / not-yet-supported styles fall back to fade with a warning (the render never fails). Phase A styles: fade, fadeblack, fadewhite, wipeleft, wiperight, wipeup, wipedown, wipetl, wipetr, wipebl, wipebr, slideleft, slideright, slideup, slidedown, smoothleft, smoothright, smoothup, smoothdown, circleopen, circleclose, dissolve, pixelize, radial.",
            "example": "fade"
          },
          "duration": {
            "type": "number",
            "exclusiveMinimum": 0,
            "description": "Transition duration in seconds (positive)."
          },
          "type": {
            "type": "string",
            "default": "xfade",
            "description": "Transition engine. Currently only \"xfade\"."
          }
        },
        "description": "Transition between this scene and the next one."
      },
      "MovieTemplate": {
        "type": "object",
        "properties": {
          "resolution": {
            "type": "string"
          },
          "width": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "height": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "fps": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "quality": {
            "type": "string"
          },
          "variables": {
            "type": "object",
            "additionalProperties": {},
            "description": "Key-value pairs interpolated into {{name}} placeholders. Variable names start with a letter and contain only [a-zA-Z0-9_]. Scene-level variables override movie-level ones with the same name."
          },
          "scenes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TemplateScene"
            },
            "minItems": 1
          },
          "elements": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TemplateElement"
            }
          }
        },
        "required": [
          "scenes"
        ],
        "additionalProperties": {},
        "description": "A Movie with json2video-compatible template syntax: {{name}} variable substitution (movie- and scene-level \"variables\", scene overrides movie) and scene \"iterate\" (data array → scene fan-out, up to 50 per iterate and 600 resolved scenes total). Inside {{ }} you may also write expressions (arithmetic, string concat with &, comparison, ternary, built-ins like $round) evaluated with JSONata; json2video operators (==, &&, ||) are accepted. A bare {{name}} is a variable reference (an undefined name is a 400); any expression follows JSONata undefined-propagation. A full-match \"{{x}}\" keeps the result type; a partial match \"a{{x}}b\" stringifies and concatenates scalars. Scenes and elements may carry an optional \"condition\" that drops them when it evaluates falsy. The server resolves the template to a plain Movie (see Movie) before rendering. Numeric/boolean fields may carry a \"{{...}}\" string."
      },
      "TemplateScene": {
        "type": "object",
        "properties": {
          "duration": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "background-color": {
            "type": "string"
          },
          "elements": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/TemplateElement"
            }
          },
          "variables": {
            "type": "object",
            "additionalProperties": {},
            "description": "Key-value pairs interpolated into {{name}} placeholders. Variable names start with a letter and contain only [a-zA-Z0-9_]. Scene-level variables override movie-level ones with the same name."
          },
          "iterate": {
            "type": "string",
            "description": "Name of a movie-level array variable. The scene is duplicated once per array item (up to 50). When an item is an object, its keys become the duplicated scene's local variables ({{key}}); scalar items duplicate the scene without injection."
          },
          "condition": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "boolean"
              },
              {
                "type": "number"
              }
            ],
            "description": "A JSONata expression (or boolean/number) controlling whether this scene/element is kept. It is evaluated against the resolved variables; a falsy result (false, \"\", 0, null, undefined) drops the scene/element before rendering. Use json2video-style operators (==, &&, ||) or JSONata operators (=, and, or)."
          }
        },
        "additionalProperties": {}
      },
      "TemplateElement": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "video",
              "image",
              "text",
              "audio"
            ]
          },
          "src": {
            "type": "string"
          },
          "text": {
            "type": "string"
          },
          "start": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "duration": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "x": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "y": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "width": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "height": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "volume": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "zoom": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "number"
              }
            ]
          },
          "muted": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "boolean"
              }
            ]
          },
          "condition": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "boolean"
              },
              {
                "type": "number"
              }
            ],
            "description": "A JSONata expression (or boolean/number) controlling whether this scene/element is kept. It is evaluated against the resolved variables; a falsy result (false, \"\", 0, null, undefined) drops the scene/element before rendering. Use json2video-style operators (==, &&, ||) or JSONata operators (=, and, or)."
          }
        },
        "required": [
          "type"
        ],
        "additionalProperties": {},
        "description": "An element where numeric/boolean fields may also be a \"{{name}}\" string. An optional \"condition\" drops the element when it evaluates falsy. After template resolution (and condition removal) the element must satisfy the strict Element schema (see Movie)."
      },
      "RenderAccepted": {
        "type": "object",
        "properties": {
          "renderId": {
            "type": "string"
          },
          "bucketName": {
            "type": "string"
          },
          "reservedCredits": {
            "type": "number"
          }
        },
        "required": [
          "renderId",
          "bucketName",
          "reservedCredits"
        ]
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": {
            "type": "string"
          },
          "detail": {
            "type": "string"
          }
        },
        "required": [
          "error"
        ]
      },
      "RenderRequest": {
        "allOf": [
          {
            "$ref": "#/components/schemas/MovieTemplate"
          },
          {
            "type": "object",
            "properties": {
              "webhook": {
                "$ref": "#/components/schemas/RenderWebhook"
              }
            }
          }
        ]
      },
      "RenderWebhook": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "HTTPS endpoint that vidhook POSTs a completion event to when the render finishes (after billing is settled). The URL is SSRF-validated; private, loopback, and cloud-metadata addresses are rejected with 400.",
            "example": "https://example.com/hooks/vidhook"
          },
          "secret": {
            "type": "string",
            "description": "Optional signing secret. When provided, the completion request carries an X-Vidhook-Signature header (see endpoint description). When omitted, the request is sent unsigned."
          }
        },
        "required": [
          "url"
        ]
      },
      "RenderValidationResult": {
        "type": "object",
        "properties": {
          "valid": {
            "type": "boolean",
            "enum": [
              true
            ]
          },
          "estimatedCredits": {
            "type": "number"
          }
        },
        "required": [
          "valid",
          "estimatedCredits"
        ]
      },
      "UsageResponse": {
        "type": "object",
        "properties": {
          "balance": {
            "type": "object",
            "properties": {
              "paidAvailable": {
                "type": "number",
                "description": "Spendable paid credits.",
                "example": 1200
              },
              "freeAvailable": {
                "type": "number",
                "description": "Spendable free (weekly) credits.",
                "example": 180
              },
              "reserved": {
                "type": "number",
                "description": "Credits currently held by in-flight renders.",
                "example": 5
              }
            },
            "required": [
              "paidAvailable",
              "freeAvailable",
              "reserved"
            ]
          },
          "recentActivity": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/RenderActivityEntry"
            },
            "description": "Most recent renders (newest first), one entry per render, synthesized from the ledger."
          }
        },
        "required": [
          "balance",
          "recentActivity"
        ]
      },
      "RenderActivityEntry": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "Reservation key (a ULID, sortable by time) that correlates this render.",
            "example": "01J8X2K9ZB7Q3M4N5P6R7S8T9V"
          },
          "credits": {
            "type": "number",
            "description": "Credits reserved for this render.",
            "example": 5
          },
          "bucket": {
            "type": "string",
            "enum": [
              "paid",
              "free"
            ],
            "description": "Balance bucket the credits were reserved from (paid = live key, free = test key).",
            "example": "paid"
          },
          "status": {
            "type": "string",
            "enum": [
              "processing",
              "succeeded",
              "failed"
            ],
            "description": "Render state derived from the ledger: `processing` (reserve only, still running), `succeeded` (settled — render finished and credits were consumed), or `failed` (released — render failed/timed out and the reserved credits were returned).",
            "example": "succeeded"
          },
          "createdAt": {
            "type": "string",
            "description": "When the render was reserved (ISO-8601).",
            "example": "2026-06-30T10:00:00.000Z"
          },
          "finalizedAt": {
            "type": "string",
            "description": "When the render finished (settled or released; ISO-8601). Absent while processing.",
            "example": "2026-06-30T10:01:30.000Z"
          }
        },
        "required": [
          "id",
          "credits",
          "bucket",
          "status",
          "createdAt"
        ]
      },
      "RenderProgress": {
        "type": "object",
        "properties": {
          "done": {
            "type": "boolean"
          },
          "overallProgress": {
            "type": [
              "number",
              "null"
            ]
          },
          "outputFile": {
            "type": [
              "string",
              "null"
            ]
          },
          "fatalErrorEncountered": {
            "type": "boolean"
          },
          "errors": {
            "type": "array",
            "items": {}
          }
        },
        "required": [
          "done",
          "overallProgress",
          "outputFile",
          "fatalErrorEncountered",
          "errors"
        ]
      }
    },
    "parameters": {}
  },
  "paths": {
    "/renders": {
      "post": {
        "operationId": "submitRender",
        "summary": "Submit a render",
        "description": "Submit a Movie definition and start an asynchronous render. Returns a render id.\n\nThe body accepts json2video-compatible template syntax (see the MovieTemplate schema): `{{name}}` variable substitution via movie- and scene-level `variables` (a scene variable overrides a movie variable of the same name), and a scene `iterate` that names a movie-level array variable to fan the scene out into one copy per item (up to 50 per iterate, 600 resolved scenes total; exceeding either is a 400). When an iterate item is an object its keys become that copy’s local variables. Inside `{{ }}` you may also write expressions (arithmetic, string concat, comparison, ternary, built-ins) evaluated with JSONata; json2video operators (`==`, `&&`, `||`) are accepted. A bare `{{name}}` is a variable reference (undefined names are a 400); expressions follow JSONata undefined-propagation. A full-match `\"{{x}}\"` keeps the value type; a partial match `\"a{{x}}b\"` stringifies and concatenates scalars. Scenes and elements may carry an optional `condition` that drops them when it evaluates falsy. The server resolves the template to a plain Movie before rendering.\n\nOptionally include a `webhook` object (alongside the Movie fields) to receive a completion notification. When the render finishes, vidhook POSTs a JSON body to `webhook.url`:\n\n```\n{\n  \"renderId\": \"string\",\n  \"status\": \"success\" | \"error\" | \"timeout\",\n  \"reservedCredits\": number,\n  \"timestamp\": \"ISO-8601 string\",\n  \"outputUrl\": \"string (success only)\",\n  \"errors\": [{ \"message\": \"string\", \"name\": \"string\" }]  (error only)\n}\n```\n\nIf `webhook.secret` is set, the request carries header `X-Vidhook-Signature: t=<unix>,v1=<hex>` where `v1 = HMAC-SHA256(secret, \"<t>.<body>\")` and `<body>` is the exact raw request body. To verify: recompute the HMAC over `\"<t>.<rawBody>\"`, compare hex (constant-time), and reject stale timestamps (e.g. older than 5 minutes). Delivery is best-effort and never affects billing.",
        "tags": [
          "Renders"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenderRequest"
              },
              "examples": {
                "minimal": {
                  "summary": "A minimal single-scene movie (custom 640×360).",
                  "value": {
                    "resolution": "custom",
                    "width": 640,
                    "height": 360,
                    "fps": 30,
                    "scenes": [
                      {
                        "background-color": "#000000",
                        "elements": [
                          {
                            "type": "text",
                            "text": "Hello, vidhook"
                          }
                        ]
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Render accepted and started.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RenderAccepted"
                },
                "examples": {
                  "accepted": {
                    "summary": "Render accepted; poll status with renderId + bucketName.",
                    "value": {
                      "renderId": "ade2napmqt",
                      "bucketName": "remotionlambda-apnortheast1-xxxx",
                      "reservedCredits": 12
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid request. Returned when the Movie definition is malformed, or when the render exceeds the maximum size (resolution × duration). The size limit corresponds to roughly 600 credits, e.g. up to ~150 seconds at 4K (3840×2160) or ~600 seconds at full-hd.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "402": {
            "description": "Insufficient credits to start the render (no render is launched).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "API key is not authorized (revoked, unknown, or malformed).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/renders/validate": {
      "post": {
        "operationId": "validateRender",
        "summary": "Validate a render and estimate its cost",
        "description": "Validate a Movie definition (and optional `webhook`) without starting a render, and return the estimated credit cost. Use this to check a request and preview its cost before calling POST /renders.\n\nNo render is launched and no credits are reserved, so this endpoint never returns 402 (insufficient credits) — it does not check or touch the credit balance. The returned `estimatedCredits` is the same value POST /renders would reserve (`reservedCredits`) for the identical request body. Invalid Movie definitions, an SSRF-rejected `webhook.url`, or a render that exceeds the maximum size (resolution × duration, ~600 credits) are rejected with 400, matching POST /renders.",
        "tags": [
          "Renders"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenderRequest"
              },
              "examples": {
                "minimal": {
                  "summary": "A minimal single-scene movie (custom 640×360).",
                  "value": {
                    "resolution": "custom",
                    "width": 640,
                    "height": 360,
                    "fps": 30,
                    "scenes": [
                      {
                        "background-color": "#000000",
                        "elements": [
                          {
                            "type": "text",
                            "text": "Hello, vidhook"
                          }
                        ]
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The request is valid; estimatedCredits is the cost POST /renders would reserve.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RenderValidationResult"
                },
                "examples": {
                  "valid": {
                    "summary": "The request is valid; estimatedCredits previews the cost.",
                    "value": {
                      "valid": true,
                      "estimatedCredits": 12
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid request. Returned when the Movie definition is malformed, the webhook URL is rejected by SSRF validation, or the render exceeds the maximum size (resolution × duration).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "API key is not authorized (revoked, unknown, or malformed).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/usage": {
      "get": {
        "operationId": "getUsage",
        "summary": "Get balance and recent render activity",
        "description": "Return the current credit balance and a feed of the most recent renders in one response. Use this to check how many credits remain and how recent renders turned out.\n\n`balance` holds spendable credits (`paidAvailable` from live keys, `freeAvailable` from the weekly free grant) and `reserved` (credits held by in-flight renders). `recentActivity` is newest-first, one entry per render, synthesized from the credit ledger: each entry carries the reserved `credits`, the `bucket` they came from, and a `status` derived from the ledger — `processing` (still running), `succeeded` (finished and credits consumed), or `failed` (the render failed/timed out and the credits were returned). `finalizedAt` is set once a render finishes (succeeded or failed) and is absent while processing.",
        "tags": [
          "Renders"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "responses": {
          "200": {
            "description": "Current balance and recent render activity.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UsageResponse"
                },
                "examples": {
                  "usage": {
                    "summary": "Current balance and the most recent renders.",
                    "value": {
                      "balance": {
                        "paidAvailable": 1200,
                        "freeAvailable": 180,
                        "reserved": 5
                      },
                      "recentActivity": [
                        {
                          "id": "01J8X2K9ZB7Q3M4N5P6R7S8T9V",
                          "credits": 5,
                          "bucket": "paid",
                          "status": "succeeded",
                          "createdAt": "2026-06-30T10:00:00.000Z",
                          "finalizedAt": "2026-06-30T10:01:30.000Z"
                        }
                      ]
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "API key is not authorized (revoked, unknown, or malformed).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/renders/{renderId}": {
      "get": {
        "operationId": "getRenderStatus",
        "summary": "Get render status",
        "description": "Poll the progress of a render. When done, outputFile holds the result URL.",
        "tags": [
          "Renders"
        ],
        "security": [
          {
            "apiKey": []
          }
        ],
        "parameters": [
          {
            "schema": {
              "type": "string",
              "example": "ade2napmqt"
            },
            "required": true,
            "name": "renderId",
            "in": "path"
          },
          {
            "schema": {
              "type": "string",
              "example": "remotionlambda-apnortheast1-xxxx"
            },
            "required": true,
            "name": "bucketName",
            "in": "query"
          }
        ],
        "responses": {
          "200": {
            "description": "Current render progress.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RenderProgress"
                },
                "examples": {
                  "rendering": {
                    "summary": "Still rendering (done=false).",
                    "value": {
                      "done": false,
                      "overallProgress": 0.42,
                      "outputFile": null,
                      "fatalErrorEncountered": false,
                      "errors": []
                    }
                  },
                  "finished": {
                    "summary": "Finished; outputFile holds the result URL.",
                    "value": {
                      "done": true,
                      "overallProgress": 1,
                      "outputFile": "https://remotionlambda-apnortheast1-xxxx.s3.amazonaws.com/renders/ade2napmqt/out.mp4",
                      "fatalErrorEncountered": false,
                      "errors": []
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Missing parameter.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "API key is not authorized (revoked, unknown, or malformed).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "404": {
            "description": "Render not found for the authenticated key owner.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "webhooks": {}
}
