Git Inbox Mirror of the ffmpeg-devel mailing list - see https://ffmpeg.org/mailman/listinfo/ffmpeg-devel
 help / color / mirror / Atom feed
From: ayosec via ffmpeg-devel <ffmpeg-devel@ffmpeg.org>
To: ffmpeg-devel@ffmpeg.org
Cc: ayosec <code@ffmpeg.org>
Subject: [FFmpeg-devel] [PATCH] Add drawvg video filter. (PR #20458)
Date: Sun, 07 Sep 2025 22:50:16 -0000
Message-ID: <175728541704.25.13938384286569916058@463a07221176> (raw)

PR #20458 opened by ayosec
URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/20458
Patch URL: https://code.ffmpeg.org/FFmpeg/FFmpeg/pulls/20458.patch

This pull-request adds _drawvg_, a new filter to draw vector graphics on top of a video.

This description is an updated version of the [proposal from a few weeks ago](https://ffmpeg.org/pipermail/ffmpeg-devel/2025-August/347825.html) in the ffmpeg-devel mailing list.


## What is drawvg

drawvg is a filter to render vector graphics on top of video frames.

The render is done by executing a script written in its own language, called VGS (_Vector Graphics Script_). The script consists of a series of commands to describe 2D graphics, which are rendered using the libcairo library.

VGS is not intended to be used as a general-purpose language. Since its scope is limited, it prioritizes being concise and easy to use. The syntax is heavily inspired by languages like [Magick Vector Graphics](https://imagemagick.org/script/magick-vector-graphics.php), or [SVG's `<path>`](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/path).

Some features of the syntax (like using spaces to separate arguments) are also present in languages like TCL or shell scripts. Many command names are taken from PostScript.

Scripts can use FFmpeg expressions to describe graphics dynamically, so they can compute coordinates based on frame dimensions, frame metadata, generate random values, read pixel colors from the video frame, etc.

For example, to draw a blue circle in the middle of a frame:

    circle (w / 2) (h / 2) 100
    setcolor blue
    fill

Then:

```bash
$ ffplay -i example.webm -vf 'drawvg=file=blue-circle.vgs'
```

There is a [website with some examples](https://ayosec.github.io/ffmpeg-drawvg/) on how to integrate drawvg with other FFmpeg filters, and also a [playground](https://ayosec.github.io/ffmpeg-drawvg/playground/) to experiment with the capabilities of the language.


## Why is it useful for FFmpeg

FFmpeg has some filters to modify video frames, like `drawbox`, `xfade`, `geq`, `drawtext`, `drawgrid`, etc.

I guess that most users will only need filters like `scale` or `crop`, but for some other users, the ability to draw vector graphics makes possible to perform more complex operations, like adding annotations, or creating masks for custom transitions.

The implementation is much smaller than I was expecting. The [file `vf_drawvg.c`](https://code.ffmpeg.org/ayosec/FFmpeg/src/branch/drawvg-filter/libavfilter/vf_drawvg.c), which contains the whole implementation of `drawvg` (the parser and the interpreter for VGS, and the class definition for the filter) is almost 2700 lines (including empty lines and comments).

## Why not SVG

FFmpeg can load SVG images with librsvg. While SVG is much more powerful than drawvg, the images are static, and can't rely on any information from FFmpeg itself.

A drawvg script can compute coordinates and colors with information from to the video (like dimensions, timestamp, colors from pixels, frame metadata, etc).

## Why libcairo

There are two reasons to choose libcairo:

First, it is a well-tested, stable, and portable library.

Second, librsvg depends on libcairo, so any installation of FFmpeg that supports SVG is already using libcairo. I expect that, since there are no new dependencies, distro maintainers should not need to make changes on how their FFmpeg package is built in order to support this new filter.

## Why a custom language

Before starting to work on the implementation I was considering to reuse an existing interpreter, like Lua or a lightweight JavaScript implementation (QuickJS, Duktape, MuJS, etc.). But this option was discarded for three reasons:

First, it adds more external dependencies to FFmpeg.

Second, a filter like this does not need the complexity of a general purpose language. Even when both Lua and JavaScript are quite simple, we can have a more minimalistic language to describe what must be drawn by the filter. And, if someone really needs to create a more complex script, they may prefer something different to Lua/JS anyways.

Third, by using a custom language, we can have full integration with the existing language for FFmpeg expressions. Thus, users with some experience writing complex filtergraphs can use a familiar syntax.

## Interpreter Implementation

The whole implementation is in the [`libavfilter/vf_drawvg.c` file](https://code.ffmpeg.org/ayosec/FFmpeg/src/branch/drawvg-filter/libavfilter/vf_drawvg.c). There are comments to describe each section, but the general idea is quite simple:

* In the `init` function of the filter, the source is parsed to generate bytecode (an array of `struct VGSStatement` instances).
* For each video frame, an interpreter translates the bytecode to libcairo functions.

## Filter Options

The options `script`/`s` are used to set the source of the script to execute by the filter.

The option `file` does the same, but the source is read from a file.

The filter requires only one option. If none or both options are given, it returns an error in the `init` function.

## Documentation

The language to write drawvg scripts is described in [`doc/drawvg-reference.texi`](https://code.ffmpeg.org/ayosec/FFmpeg/src/branch/drawvg-filter/doc/drawvg-reference.texi). That file is added to `doc/Makefile`, so [a `drawvg-reference.html` file](https://ayosec.github.io/ffmpeg-drawvg/ffmpeg-docs/drawvg-reference.html) is generated in `make documentation`. This document contains a lot of examples, and it is almost half of the diff in this pull-request.

There is [another version of that document](https://ayosec.github.io/ffmpeg-drawvg/playground/docs/langref.html). It has the same content, but the examples are rendered.

`doc/filters.texi` includes a short description of the filter, the arguments, some examples, and a link to the language reference.


## Tests

There are two tests for drawvg:

* `fate-filter-drawvg-interpreter`

    This test is to verify that the drawvg interpreter executes the expected libcairo functions. The functions are [replaced by mocks](https://code.ffmpeg.org/ayosec/FFmpeg/src/commit/34400fd5dd1536fc34089eb661d50cadc60deb10/libavfilter/tests/drawvg.c#L58-L125), which just prints the function name and the arguments.

* `fate-filter-drawvg-video`

    This test executes a [small drawvg script](https://code.ffmpeg.org/ayosec/FFmpeg/src/branch/drawvg-filter/tests/ref/lavf/drawvg.lines) to render a square, by using `CMD = video_filter` (like many other filters in `tests/fate/filter-video.mak`). The purpose of this test is to verify that drawvg can update video frames.

During development and tests, I always built FFmpeg with `--toolchain=clang-asan`, so I hope that the implementation is (mostly) correct, although I'm not a C expert.

## Original History

The original commit history (before the squash) is in the [tag `drawvg-filter-orig`](https://code.ffmpeg.org/ayosec/FFmpeg/compare/master..drawvg-filter-orig). The commit messages are not very descriptive (I assumed that the pull-request was going to be a single commit, so I didn't spend too much time on writing better messages), but each commit is *much* smaller, and maybe they can help to review the implementation.



From 34400fd5dd1536fc34089eb661d50cadc60deb10 Mon Sep 17 00:00:00 2001
From: Ayose <ayosec@gmail.com>
Date: Sun, 7 Sep 2025 22:07:56 +0100
Subject: [PATCH] lavfi: add drawvg video filter.

The drawvg filter can draw vector graphics on top of a video, using libcairo. It
is enabled if libcairo is detected at compile time.

The language for drawvg scripts is documented in doc/drawvg-reference.texi.

There are two new tests:

- 'fate-filter-drawvg-interpreter' launch a script with most commands, and
  verify which libcairo functions are executed.
- 'fate-filter-drawvg-video' render a very simple image, just to verify that
  libcairo is working as expected.

Signed-off-by: Ayose <ayosec@gmail.com>
---
 Changelog                                |    1 +
 configure                                |    3 +
 doc/Makefile                             |    1 +
 doc/drawvg-reference.texi                | 2772 ++++++++++++++++++++++
 doc/filters.texi                         |   70 +
 libavfilter/Makefile                     |    5 +
 libavfilter/allfilters.c                 |    1 +
 libavfilter/tests/.gitignore             |    1 +
 libavfilter/tests/drawvg.c               |  347 +++
 libavfilter/version.h                    |    2 +-
 libavfilter/vf_drawvg.c                  | 2699 +++++++++++++++++++++
 tests/fate/filter-video.mak              |   11 +
 tests/ref/fate/filter-drawvg-interpreter |  130 +
 tests/ref/fate/filter-drawvg-video       |    1 +
 tests/ref/lavf/drawvg.all                |  100 +
 tests/ref/lavf/drawvg.lines              |   10 +
 16 files changed, 6153 insertions(+), 1 deletion(-)
 create mode 100644 doc/drawvg-reference.texi
 create mode 100644 libavfilter/tests/drawvg.c
 create mode 100644 libavfilter/vf_drawvg.c
 create mode 100644 tests/ref/fate/filter-drawvg-interpreter
 create mode 100644 tests/ref/fate/filter-drawvg-video
 create mode 100644 tests/ref/lavf/drawvg.all
 create mode 100644 tests/ref/lavf/drawvg.lines

diff --git a/Changelog b/Changelog
index aaab8d9b54..e81729de62 100644
--- a/Changelog
+++ b/Changelog
@@ -4,6 +4,7 @@ releases are sorted from youngest to oldest.
 version <next>:
 - ffprobe -codec option
 - EXIF Metadata Parsing
+- drawvg filter
 
 
 version 8.0:
diff --git a/configure b/configure
index a38cbdb89f..647ed912cd 100755
--- a/configure
+++ b/configure
@@ -200,6 +200,7 @@ External library support:
   --disable-avfoundation   disable Apple AVFoundation framework [autodetect]
   --enable-avisynth        enable reading of AviSynth script files [no]
   --disable-bzlib          disable bzlib [autodetect]
+  --disable-cairo          disable cairo [autodetect]
   --disable-coreimage      disable Apple CoreImage framework [autodetect]
   --enable-chromaprint     enable audio fingerprinting with chromaprint [no]
   --enable-frei0r          enable frei0r video filtering [no]
@@ -1863,6 +1864,7 @@ EXTERNAL_AUTODETECT_LIBRARY_LIST="
     appkit
     avfoundation
     bzlib
+    cairo
     coreimage
     iconv
     libxcb
@@ -3959,6 +3961,7 @@ dnn_detect_filter_select="dnn"
 dnn_processing_filter_select="dnn"
 drawtext_filter_deps="libfreetype libharfbuzz"
 drawtext_filter_suggest="libfontconfig libfribidi"
+drawvg_filter_deps="cairo"
 elbg_filter_deps="avcodec"
 eq_filter_deps="gpl"
 erosion_opencl_filter_deps="opencl"
diff --git a/doc/Makefile b/doc/Makefile
index 98d29f1c66..2112aff737 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -28,6 +28,7 @@ HTMLPAGES   = $(AVPROGS-yes:%=doc/%.html) $(AVPROGS-yes:%=doc/%-all.html) $(COMP
               doc/mailing-list-faq.html                                 \
               doc/nut.html                                              \
               doc/platform.html                                         \
+              doc/drawvg-reference.html                                 \
               $(SRC_PATH)/doc/bootstrap.min.css                         \
               $(SRC_PATH)/doc/style.min.css                             \
               $(SRC_PATH)/doc/default.css                               \
diff --git a/doc/drawvg-reference.texi b/doc/drawvg-reference.texi
new file mode 100644
index 0000000000..f5ce041df3
--- /dev/null
+++ b/doc/drawvg-reference.texi
@@ -0,0 +1,2772 @@
+@documentencoding UTF-8
+
+@settitle drawvg - Language Reference
+@titlepage
+@center @titlefont{drawvg - Language Reference}
+@end titlepage
+
+@top
+
+@contents
+
+@macro codeexample {block}
+@cartouche Example
+\block\
+@end cartouche
+@end macro
+
+@macro vgscmd {name}
+@ref{cmd_\name\,,@code{\name\}}
+@end macro
+
+@chapter Introduction
+
+drawvg (@emph{draw vector graphics}) is a language to draw
+two-dimensional graphics on top of video frames. It is not intended to
+be used as a general-purpose language. Since its scope is limited, it
+prioritizes being concise and easy to use.
+
+For example, using the
+@uref{https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API,Canvas
+API} we can render a triangle running this code in a Web browser:
+
+@example
+const canvas = document.getElementById("canvas");
+const ctx = canvas.getContext("2d");
+
+ctx.beginPath();
+ctx.moveTo(125, 50);
+ctx.lineTo(100, 100);
+ctx.lineTo(150, 100);
+ctx.closePath();
+ctx.stroke();
+@end example
+
+The same triangle can be written with this drawvg script:
+
+@example
+moveto 125 50
+lineto 100 100 150 100
+closepath
+stroke
+@end example
+
+It can be shortened using the aliases for @vgscmd{moveto}, @vgscmd{lineto},
+and @vgscmd{closepath}:
+
+@example
+M 125 50
+L 100 100 150 100
+Z
+stroke
+@end example
+
+Both newlines (@code{U+000A}) and spaces (@code{U+0020}) can be used
+interchangeably as delimiters, so multiple commands can appear on the
+same line:
+
+@example
+M 125 50 L 100 100 150 100 Z
+stroke
+@end example
+
+@macro ffexprs
+@ref{Expression Evaluation,,FFmpeg expressions,ffmpeg-utils}
+@end macro
+
+Finally, drawvg can use @ffexprs{} and frame metadata in command arguments. In
+this example, we are using the variables @var{w} (frame width) and @var{h}
+(frame height) to create a circle in the middle of the frame.
+
+@example
+circle (w / 2) (h / 2) (w / 3)
+stroke
+@end example
+
+Many commands are a direct equivalent to a function in the
+@uref{https://www.cairographics.org/,Cairo graphics library}. For such
+commands, the reference below provides a link to the related Cairo
+documentation.
+
+@chapter Syntax
+
+@macro svgpathlink
+@uref{https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/path,SVG's @code{<path>}}
+@end macro
+
+The syntax is heavily inspired by languages like
+@uref{https://imagemagick.org/script/magick-vector-graphics.php,Magick
+Vector Graphics}, or @svgpathlink{}. Many command names are taken from
+@uref{https://en.wikipedia.org/wiki/PostScript,PostScript}.
+
+@section Structure
+
+A drawvg script consists of a series of commands to describe 2D
+graphics.
+
+A command is an identifier (like @vgscmd{setcolor} or @vgscmd{lineto})
+followed by its arguments. Each item in the code (command name,
+arguments, etc.) is separated by any of the following characters:
+
+@itemize
+@item Space (@code{' '})
+@item Comma (@code{','})
+@item Newline (@code{'\n'})
+@item Tabs (@code{'\t'})
+@item Return (@code{'\r'})
+@end itemize
+
+The beginning of the item indicates how it will be interpreted:
+
+@table @r
+@item @code{//}
+Comment
+@item @code{0}, @dots{}, @code{9}, @code{+}, @code{-}
+Number literal
+@item @code{(}
+Expression
+@item @code{@{}, @code{@}}
+Block delimiters
+@item Anything else
+Name of a command, a color, etc.
+@end table
+
+@section Comments
+
+Comments start with two slashes (@code{//}), and stop at the end of the
+line (either a @code{\n}, or the end of the script).
+
+@example
+circle 100 100 50 // this is ignored
+fill
+
+// this is also ignored
+@end example
+
+@code{//} must appear after a space, or at the beginning of the line. If
+@code{//} is preceded by any non-blank character, the parser will
+consider @code{//} as part of the previous item.
+
+For example, in this script:
+
+@example
+circle 10 10 50// something
+@end example
+
+The parser throws an error because it tries to parse @code{50//} as a
+number literal.
+
+@section Commands
+
+The way commands are parsed is inspired by @svgpathlink{}:
+
+@itemize
+@item
+Every command in the script starts with its name, and it is followed by
+zero or more arguments.
+
+@item
+There are no explicit delimiters between commands or arguments.
+
+Most programming languages expect characters like parenthesis, commas,
+or semicolons, to separate items. For example:
+
+@example
+moveto(10, 10); lineto(20, 30);
+@end example
+
+The equivalent in drawvg is:
+
+@example
+moveto 10 10 lineto 20 30
+@end example
+
+@item
+If the command has no arguments (like @vgscmd{closepath} or
+@vgscmd{stroke}), the next command starts at the next item.
+
+@end itemize
+
+@codeexample{
+In the next script there are 4 different commands:
+
+@example
+newpath rect 10 20 30 40 setcolor teal fill
+@end example
+
+@enumerate
+@item
+@vgscmd{newpath} requires no arguments.
+
+@item
+@vgscmd{rect} requires 4 arguments, so it takes the next 4 numbers.
+
+@item
+@vgscmd{setcolor} requires 1 argument, so it takes the word @code{teal}.
+
+@item
+@vgscmd{fill} requires no arguments.
+@end enumerate
+}
+
+@subsection Single-Letter Aliases
+
+Most commands in @svgpathlink{} are also present in drawvg. For some of them,
+there is an alias to a longer name:
+
+@itemize
+@item @vgscmd{curveto} for @vgscmd{C}.
+@item @vgscmd{rcurveto} for @vgscmd{c}.
+@item @vgscmd{lineto} for @vgscmd{L}.
+@item @vgscmd{rlineto} for @vgscmd{l}.
+@item @vgscmd{moveto} for @vgscmd{M}.
+@item @vgscmd{rmoveto} for @vgscmd{m}.
+@item @vgscmd{closepath} for @vgscmd{Z}, @vgscmd{z}.
+@end itemize
+
+Other commands only exist in a single-letter form:
+
+@itemize
+@item @vgscmd{H}, @vgscmd{h}
+@item @vgscmd{Q}, @vgscmd{q}
+@item @vgscmd{S}, @vgscmd{s}
+@item @vgscmd{V}, @vgscmd{v}
+@item @vgscmd{T}, @vgscmd{t}
+@end itemize
+
+This makes it possible to use a path in SVG to create the same shape in
+a drawvg script.
+
+@anchor{implicit commands}
+@subsection Implicit Commands
+
+For many commands, the name can be omitted when it is used multiple
+times in successive calls.
+
+In the reference below, these commands has a @emph{Can be Implicit} note
+in their signature.
+
+@codeexample {
+For example, in this script:
+
+@example
+M 50 50
+l 10 10
+l 10 -10
+l 10 10
+l 10 -10
+l 10 10
+stroke
+@end example
+
+After the first call to @vgscmd{l} (alias to @vgscmd{rlineto}), the command
+can be executed without the name, so it can be written as:
+
+@example
+M 50 50
+l 10 10 10 -10 10 10 10 -10 10 10
+stroke
+@end example
+}
+
+To reuse the same command (@vgscmd{l}, in the previous example), the
+parser checks if the item after the last argument is a numeric value,
+like a number literal or a FFmpeg expression.
+
+@codeexample{
+In this example:
+
+@example
+l 10 20 30 40 stroke
+@end example
+
+@vgscmd{l} requires 2 arguments, and can be implicit, so the parser
+performs this operation:
+
+@enumerate
+
+@item
+Takes the two next items (@code{10} and @code{20}) and emits the first
+instruction.
+
+@item
+Checks if the item after @code{20} is a numeric value. Since it is
+@code{30}, it takes @code{30} and @code{40} and emits the second
+instruction (@code{l 30 40}).
+
+@item
+Checks if the next item after @code{40} is a numeric value, but it is a
+command (@vgscmd{stroke}), so it stops reusing @vgscmd{l}.
+
+@end enumerate
+}
+
+This is another feature taken from @svgpathlink{}. An important difference with
+SVG is that the separator between items is always required. In SVG, it can be
+omitted in some cases. For example, the expression @code{m1-2} is equivalent to
+@code{m 1 -2} in SVG, but a syntax error in drawvg.
+
+@section Arguments
+
+Most commands expect numeric arguments, like number literals, variable
+names, or expressions.
+
+@vgscmd{setcolor} and @vgscmd{colorstop} expect a color.
+
+@vgscmd{setlinecap} and @vgscmd{setlinejoin} expect a constant value.
+
+@subsection Number Literals
+
+A number literal is an item in the script that represents a constant
+value. Any item that starts with a decimal digit (between @code{0} and
+@code{9}), a @code{-} or a @code{+}, is interpreted as a number literal.
+
+The value is parsed with
+@uref{https://ffmpeg.org/doxygen/trunk/eval_8c.html#a7d21905c92ee5af0bb529d2daf8cb7c3,@code{av_strtod}}.
+It supports the prefix @code{0x} to write a value with hexadecimal
+digits, and
+@uref{https://ffmpeg.org/ffmpeg-utils.html#:~:text=The%20evaluator%20also%20recognizes%20the%20International%20System%20unit%20prefixes,many
+units} (like @code{K} or @code{GiB}).
+
+In the next example, all literals represent the same value:
+
+@example
+10000
+1e4
+10K
+0x2710
+@end example
+
+@subsection Expressions
+
+@ffexprs{} can be used as arguments for any command that expects a numeric
+argument. The expression must be enclosed in parenthesis.
+
+@codeexample {
+The variables @var{w} and @var{h} represent the width and height of the
+frame. We can compute the center of the frame by dividing them by @code{2}:
+
+@example
+M (w / 2) (h / 2)
+@end example
+
+They can also contain parenthesis (to group operations, to call functions,
+etc):
+
+@example
+moveto
+    ((w + 10) / 2)      // x
+    (h / (2 * cos(t)))  // y
+@end example
+}
+
+The variables @var{n} and @var{t} can be used to compute a value that changes
+over time.
+
+@codeexample {
+To draw a circle oscillating from left to right, we can use an
+expression based on @code{sin(t)} for the @code{x} coordinate:
+
+@example
+circle
+    (w / 2 + sin(2 * t) * w / 4)  // x
+    (h / 2)                       // y
+    (w / 5)                       // radius
+
+stroke
+@end example
+}
+
+Expressions can be split in multiple lines, but they can't contain
+comments within them.
+
+@example
+moveto   // This is a comment.
+    (w   // This is part of the expression, not a comment.
+     + h)
+@end example
+
+@subsection Variable Names
+
+When an expression is only a reference to a variable, the parenthesis
+can be omitted, and the item is just the variable name.
+
+@codeexample {
+The next 3 expressions are equivalent: in all cases, they create a
+rectangle covering the whole frame.
+
+@example
+rect (0) (0) (w) (h)
+
+rect 0 0 w h
+
+rect (0) 0 (w) h
+@end example
+}
+
+It is possible to create a variable with the same name of a command, and
+then use it as an argument. In the previous example, the item @var{h} is a
+reference to a variable (frame height), but in other contexts it may be
+a command (@vgscmd{h}).
+
+For @ref{implicit commands}, the parser prioritizes
+commands over variable names when it has to determine if the command is
+reused.
+
+@codeexample {
+In this example, the variable @var{c} is used as the first argument in two
+calls to @vgscmd{l}. However, only the first one is valid, because in the
+second call the parser recognizes @vgscmd{c} as a command.
+
+@example
+setvar c 5
+l c 10 c 15
+@end example
+
+This issue can be fixed by surrounding the start of the second call with
+parenthesis:
+
+@example
+setvar c 5
+l c 10 (c) 15
+@end example
+}
+
+@anchor{Colors}
+@subsection Colors
+
+The color to stroke and to fill paths can be set with @vgscmd{setcolor}.
+Its argument has the same syntax for colors in FFmpeg:
+
+@itemize
+@item
+A @ref{Color,,predefined color name,ffmpeg-utils}.
+
+@item
+In @code{#RRGGBB} format.
+
+@item
+Optionally, an @code{@@a} suffix can be added to set the alpha value,
+where @code{a} is a number between @code{0} and @code{1}.
+@end itemize
+
+The color can be a variable name. In that case, its value is interpreted
+as a @code{0xRRGGBBAA} code.
+
+@example
+circle 75 100 50
+setcolor #FF0000
+fill
+
+circle 125 100 50
+setvar CustomGreen 0x90EEAAFF
+setcolor CustomGreen
+fill
+
+circle 175 100 50
+setcolor blue@@0.5
+fill
+@end example
+
+The commands @vgscmd{setrgba} and @vgscmd{sethsla} allow setting colors
+using expressions.
+
+@vgscmd{defrgba} and @vgscmd{defhsla} compute the color and store it in a
+variable.
+
+@subsection Constants
+
+The argument for @vgscmd{setlinecap} and @vgscmd{setlinejoin} is an
+identifier referring to a constant value.
+
+@example
+setlinecap round
+@end example
+
+@chapter Guide
+
+@section Paths
+
+A path is a complex shape, composed by lines and curves, that can be
+used to fill a region, to stroke an outline, or to establish a clip
+region.
+
+In order to draw anything on top of a video frame, first we have to
+define a path, and then use @vgscmd{stroke} or @vgscmd{fill}.
+
+The
+@uref{https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorials/SVG_from_scratch/Paths,tutorial
+on paths in MDN} is a good introduction to the topic. It is focused on
+@svgpathlink{}, but the same concepts can be applied in drawvg.
+
+@anchor{current point}
+@subsection Current Point
+
+Some commands require a @emph{current point}. Initially, the
+@emph{current point} is set to
+@uref{https://en.wikipedia.org/wiki/NaN,@code{NaN}}. It is initialized
+with @vgscmd{M} or @vgscmd{moveto}. Other commands, like @vgscmd{lineto} or
+@vgscmd{curveto}, updates the @emph{current point} to the new end of the
+shape.
+
+The @emph{current point} can be cleared with @vgscmd{newpath}. Commands
+that clear the path, like @vgscmd{stroke} or @vgscmd{fill}, also clear the
+@emph{current point}.
+
+@codeexample {
+@vgscmd{rlineto} uses coordinates relative to the @emph{current point}.
+
+Given this script:
+
+@example
+moveto 20 100
+rlineto 150 -90
+rlineto -50 200
+closepath
+stroke
+@end example
+
+These are the coordinates of the @emph{current point} after executing
+each command:
+
+@multitable @columnfractions .5 .5
+@headitem Command @tab Current Point
+@item @code{moveto 20 100} @tab @code{20, 100}
+@item @code{rlineto 150 -90} @tab @code{170, 10}
+@item @code{rlineto -10 50} @tab @code{140, 210}
+@item @code{closepath} @tab @code{20, 100}
+@end multitable
+
+The same script can be written with single-letter aliases:
+
+@example
+M 20 100 l 150 -90 -50 200 z stroke
+@end example
+}
+
+@subsection Defining a Shape
+
+A path is defined by adding lines, curves, or basic shapes.
+
+@itemize
+@item Basic shapes
+
+@itemize
+@item @vgscmd{circle}
+@item @vgscmd{ellipse}
+@item @vgscmd{rect}
+@item @vgscmd{roundedrect}
+@end itemize
+
+@item
+Lines
+@itemize
+@item @vgscmd{M}, @vgscmd{moveto}
+@item @vgscmd{m}, @vgscmd{rmoveto}
+@item @vgscmd{H}, @vgscmd{h}
+@item @vgscmd{V}, @vgscmd{v}
+@item @vgscmd{L}, @vgscmd{lineto}
+@item @vgscmd{l}, @vgscmd{rlineto}
+@item @vgscmd{Z}, @vgscmd{z}, @vgscmd{closepath}
+@end itemize
+
+@item
+Curves
+@itemize
+@item @vgscmd{arc}, @vgscmd{arcn}
+@item @vgscmd{C}, @vgscmd{curveto},
+@item @vgscmd{c}, @vgscmd{rcurveto}
+@item @vgscmd{Q}, @vgscmd{q}
+@item @vgscmd{S}, @vgscmd{s}
+@item @vgscmd{T}, @vgscmd{t}
+@end itemize
+
+@end itemize
+
+Single-letter commands are taken from @svgpathlink{}.
+
+@anchor{fill rules}
+@subsection Fill
+
+The region within the shape defined by a path can be filled with
+@vgscmd{fill} or @vgscmd{eofill}. Each command uses a different
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-fill-rule-t,fill
+rule}:
+
+@itemize
+@item
+@vgscmd{fill} uses the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#CAIRO-FILL-RULE-WINDING:CAPS,winding
+rule}, also known as
+@uref{https://en.wikipedia.org/wiki/Nonzero-rule,nonzero rule}.
+@item
+@vgscmd{eofill} uses the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#CAIRO-FILL-RULE-EVEN-ODD:CAPS,even--odd
+rule}.
+@end itemize
+
+@codeexample{
+This script shows the difference between the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#CAIRO-FILL-RULE-WINDING:CAPS,winding}
+and
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#CAIRO-FILL-RULE-EVEN-ODD:CAPS,even--odd}
+rules:
+
+@example
+rect 50 10 100 60
+circle 150 70 40
+setcolor seagreen
+fill
+
+rect 50 130 100 60
+circle 150 190 40
+setcolor skyblue
+eofill
+@end example
+}
+
+@subsection Stroke
+
+@vgscmd{stroke} draws a line around the shape defined by the path. The
+stroke can be configured with different commands:
+
+@itemize
+@item @vgscmd{setdash}
+@item @vgscmd{setdashoffset}
+@item @vgscmd{setlinecap}
+@item @vgscmd{setlinejoin}
+@item @vgscmd{setlinewidth}
+@item @vgscmd{resetdash}
+@end itemize
+
+@codeexample{
+This example use @vgscmd{setdashoffset} to animate the stroke:
+
+@example
+moveto 0 0
+lineto w h
+
+setlinecap round
+setdash 50 50
+setlinewidth 20
+setdashoffset (hypot(w, h) * t / -3)
+setcolor seagreen
+
+stroke
+@end example
+}
+
+@subsection Clip
+
+A @uref{https://en.wikipedia.org/wiki/Clipping_(computer_graphics),clip
+region} can be established with @vgscmd{clip} and @vgscmd{eoclip}.
+
+If there is an active clip region, the new clip region will be the
+intersection between the existing one and the path. @vgscmd{resetclip}
+reset the clip region to the whole frame.
+
+@vgscmd{eoclip} uses the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#CAIRO-FILL-RULE-EVEN-ODD:CAPS,even--odd
+rule} to compute the clip region.
+
+@codeexample{
+@example
+rect 50 50 100 200
+clip
+
+circle 30 30 150
+setcolor seagreen
+fill
+
+// Draw outside the clip region.
+resetclip
+circle 30 30 150
+setlinewidth 3
+setcolor skyblue
+stroke
+@end example
+}
+
+@subsection Preserving Paths
+
+The path is cleared after any operation on it, like @vgscmd{fill} or
+@vgscmd{stroke}. To reuse the same path in multiple operations,
+@vgscmd{preserve} must be called before them.
+
+@codeexample{
+In this example, each path is used twice.
+
+@example
+circle 120 120 50
+setcolor seagreen
+preserve stroke
+clip
+
+circle 100 100 50
+setcolor skyblue
+preserve fill
+setcolor tomato
+stroke
+@end example
+}
+
+@section Variables
+
+A drawvg can use some variables, provided by the interpreter, to compute
+values in @ffexprs{}:
+
+@table @var
+@item cx
+X coordinate of the @ref{current point}.
+
+@item cy
+Y coordinate of the @ref{current point}.
+
+@item w
+Width, in pixels, of the frame.
+
+@item h
+Height, in pixels, of the frame.
+
+@item i
+The loop counter in repeat blocks.
+
+@item n
+Frame number.
+
+@item t
+Timestamp, in seconds.
+
+@item ts
+Timestamp, in seconds, of the first frame.
+
+@item duration
+Duration, in seconds, of the frame.
+@end table
+
+@anchor{User Variables}
+@subsection User Variables
+
+New variables can be created with the @vgscmd{setvar} command. It
+associates a name with a numeric value.
+
+The name must follow these rules:
+
+@itemize
+@item
+It must start with an ASCII letter or an underscore (@code{_}).
+
+@item
+It can contain only ASCII letters, underscores, and digits.
+
+@item
+It must not match the name of a variable provided by the interpreter
+(like @var{w} or @var{t}).
+@end itemize
+
+The same variable can be assigned multiple times.
+
+@codeexample{
+In this example, the result of an expression is stored in a variable
+with the name @var{progress}. Then, it is used for the @var{x} and
+@var{width} arguments of @vgscmd{rect}.
+
+@example
+setvar progress (w * (pow(mod(t / 2 + 0.5, 1), 2.5)))
+
+rect ((w - progress) / 2) 0 progress h
+
+setcolor darkblue
+fill
+@end example
+}
+
+Currently, a script can contain only 20 different variable names, but
+this limit can be modified in the future.
+
+@anchor{current pattern}
+@section Patterns
+
+The pattern for fill and stroke operations can be either a solid color,
+or a gradient.
+
+@itemize
+@item Solid colors.
+
+@itemize
+@item @vgscmd{setcolor}
+@item @vgscmd{sethsla}
+@item @vgscmd{setrgba}
+@end itemize
+
+@item Gradients.
+
+@itemize
+@item @vgscmd{lineargrad}
+@item @vgscmd{radialgrad}
+@end itemize
+
+@end itemize
+
+The pattern is not cleared after being used in a fill or stroke
+operation, but it is replaced by any command that sets a new pattern.
+
+@subsection Gradients
+
+To configure a gradient, first call to @vgscmd{lineargrad} or
+@vgscmd{radialgrad}, and then add color stops by calling @vgscmd{colorstop}
+for each stop.
+
+@codeexample{
+In this example, the whole frame is filled with a linear gradient:
+
+@example
+lineargrad 0 0 w h
+colorstop 0 skyblue
+colorstop 1 darkblue
+
+rect 0 0 w h
+fill
+@end example
+
+In this example, a radial gradient is used to simulate a sphere:
+
+@example
+radialgrad 90 90 5 120 120 100
+colorstop 0.0 #90DDFF
+colorstop 0.9 #000030
+colorstop 1.0 #000000
+
+rect 0 0 w h
+fill
+@end example
+}
+
+@subsection Variables
+
+@vgscmd{setcolor} and @vgscmd{colorstop} accept a variable name as the
+argument. When a variable is used, its value is interpreted as a
+@code{0xRRGGBBAA} code.
+
+@codeexample{
+@example
+// Use color #1020FF, alpha = 50%
+setvar someblue 0x1020FF7F
+
+setcolor someblue
+
+rect 30 30 120 120
+fill
+
+rect 90 90 120 120
+fill
+@end example
+}
+
+If a variable has the same name of a @ref{Color,,known color,ffmpeg-utils}, the
+variable has preference, and will be used instead of the predefined color.
+
+@codeexample{
+@example
+setcolor teal
+rect 30 30 120 120
+fill
+
+setvar teal 0x70AAAAFF  // Now, `teal` is #70AAAA
+setcolor teal
+rect 90 90 120 120
+fill
+@end example
+}
+
+@vgscmd{defrgba} and @vgscmd{defhsla} compute the @code{0xRRGGBBAA} value
+for a color given its color components:
+
+@itemize
+@item
+For @vgscmd{defrgba}: @emph{red}, @emph{green}, @emph{blue}, and
+@emph{alpha}.
+
+@item
+For @vgscmd{defhsla}: @emph{hue}, @emph{saturation}, @emph{lightness}, and
+@emph{alpha}.
+@end itemize
+
+Each color component must be in range @code{0} to @code{1}, except
+@emph{hue}, which is @code{0} to @code{360}.
+
+@codeexample{
+@example
+defrgba colorA 1 0.5 0.25 1     // colorA = RGB(255, 127, 63)
+defhsla colorB 200 0.75 0.25 1  // colorB = HSL(200, 75%, 25%)
+
+rect 0 0 (w / 2) h
+setcolor colorA
+fill
+
+rect (w / 2) 0 (w / 2) h
+setcolor colorB
+fill
+@end example
+}
+
+@anchor{transformation matrix}
+@section Transformations
+
+The coordinates for each command can be scaled, rotated, and translated,
+by using the following commands:
+
+@itemize
+@item @vgscmd{rotate}
+@item @vgscmd{scale}
+@item @vgscmd{scalexy}
+@item @vgscmd{translate}
+@end itemize
+
+The transformations are applied when the command is executed. They have
+no effect on the existing path, only on the new segments added to it.
+
+They are done by updating the
+@uref{https://www.cairographics.org/manual/cairo-Transformations.html,current
+transformation matrix} in the Cairo context. To reset the matrix to its
+original state, before any transformation, use @vgscmd{resetmatrix}.
+
+The transform origin for scale and rotation is initially at @code{0, 0},
+but it can be adjusted with @vgscmd{translate}.
+
+@codeexample{
+@example
+// Map (0, 0) as the center of the frame.
+translate (w / 2) (h / 2)
+
+// Scale the space as if the frame is 1x1 pixel.
+scalexy w h
+
+// Draw multiple lines with the same arguments,
+// but each one on a different rotation.
+repeat 10 @{
+    rotate (PI / 10)
+    M -0.25 0
+    H 0.25
+@}
+
+// Reset transformations, so the scale does not
+// affect stroke.
+resetmatrix
+
+stroke
+@end example
+}
+
+@anchor{State Stack}
+@section State Stack
+
+The state of a drawvg script contains all parameters used for drawing
+operations, like the current color, the transformation matrix, the
+stroke configuration, etc.
+
+The @vgscmd{save} command pushes a snapshot of the state to an internal
+stack. Later, @vgscmd{restore} pops the latest snapshot from the stack,
+and uses it as the new state.
+
+The parameters that can be saved and restored are:
+
+@itemize
+@item
+Pattern for stroke and fill operations.
+
+@itemize
+@item @vgscmd{lineargrad}
+@item @vgscmd{radialgrad}
+@item @vgscmd{setrgba}
+@item @vgscmd{setcolor}
+@item @vgscmd{sethsla}
+@end itemize
+
+@item Transformation matrix.
+
+@itemize
+@item @vgscmd{resetmatrix}
+@item @vgscmd{rotate}
+@item @vgscmd{scale}
+@item @vgscmd{scalexy}
+@item @vgscmd{translate}
+@end itemize
+
+@item Stroke configuration.
+
+@itemize
+@item @vgscmd{setdash}
+@item @vgscmd{setdashoffset}
+@item @vgscmd{setlinecap}
+@item @vgscmd{setlinejoin}
+@item @vgscmd{setlinewidth}
+@end itemize
+
+@item
+Clip region
+
+@itemize
+@item @vgscmd{clip}
+@item @vgscmd{resetclip}
+@end itemize
+
+@end itemize
+
+@anchor{Frame Metadata}
+@section Frame Metadata
+
+Some FFmpeg filters add metadata to frames. The command
+@vgscmd{getmetadata} can read metadata items containing a numeric value,
+and store it in a variable that can be used for command arguments.
+
+@codeexample{
+The @code{cropdetect} filter computes the parameters to remove empty
+regions around the video. These parameters are accessible in the
+@code{lavfi.cropdetect} keys of the frame metadata.
+
+@example
+// Get metadata from cropdetect filter and store it
+// in `cd*` variables.
+getmetadata cdx lavfi.cropdetect.x
+getmetadata cdy lavfi.cropdetect.y
+getmetadata cdw lavfi.cropdetect.w
+getmetadata cdh lavfi.cropdetect.h
+
+rect cdx cdy cdw cdh
+setcolor yellow@@0.5
+setlinewidth 10
+stroke
+@end example
+
+To test the script, copy it to a @code{drawcropdetect.vgs} file, and
+then execute a command like this:
+
+@example
+ffplay -i example-video.webm -vf 'cropdetect, drawvg=file=drawcropdetect.vgs'
+@end example
+}
+
+@section @code{if} / @code{repeat} Statements
+
+There is limited support for control flow statements: only @vgscmd{if} and
+@vgscmd{repeat}.
+
+Both commands receive two arguments: an expression and a block.
+
+@example
+if (condition) @{
+    // commands
+@}
+
+repeat (count) @{
+    // commands
+@}
+@end example
+
+@vgscmd{if} executes its block if the result of @code{(condition)} is not
+zero.
+
+@vgscmd{repeat} executes its block the number of times specified by
+@code{(count)}. In each iteration, the variable @var{i} is used as a
+@uref{https://en.wikipedia.org/wiki/For_loop#Loop_counters,loop
+counter}.
+
+If the result of the expression is not a finite number (like
+@uref{https://en.wikipedia.org/wiki/NaN,@code{NaN}}) the block is not
+executed.
+
+@anchor{comp-operators}
+@subsection Comparison and Logical Operators
+
+@ffexprs{} only supports arithmetic operators (like @code{+} for addition).
+Comparison operators (like @code{!=}) are supported via functions, while
+logical operators (like @code{&&} for @code{AND}) can be emulated with
+arithmetic operations.
+
+@multitable @columnfractions .5 .5
+@headitem Expression @tab FFmpeg Equivalent
+@item @code{x = y} @tab @code{eq(x, y)}
+@item @code{x < y} @tab @code{lt(x, y)}
+@item @code{x > y} @tab @code{gt(x, y)}
+@item @code{x ≤ y} @tab @code{lte(x, y)}
+@item @code{x ≥ y} @tab @code{gte(x, y)}
+@item @code{a ≤ x ≤ b} @tab @code{between(x, a, b)}
+@end multitable
+
+Logical operators can be emulated with multiplication (for @code{AND}),
+or addition (for @code{OR}):
+
+@multitable @columnfractions .5 .5
+@headitem Expression @tab FFmpeg Equivalent
+@item @code{x OR y} @tab @code{x + y}
+@item @code{x AND y} @tab @code{x * y}
+@end multitable
+
+@codeexample{
+In other programming languages, a code like this:
+
+@example
+if (x > y && z != 1) @{
+    // …
+@}
+@end example
+
+Can be written for drawvg like this:
+
+@example
+if (gt(x, y) * not(eq(z, 1))) @{
+    // …
+@}
+@end example
+}
+
+@subsection Early Exit
+@vgscmd{break} causes a @vgscmd{repeat} loop to be terminated immediately.
+
+If it is executed outside a @vgscmd{repeat} block, it terminates the whole
+script, or the current procedure.
+
+@codeexample{
+In this example, we are using the @ref{func-randomg,@code{randomg}} function
+to draw a line with random segments.
+
+The loop can be executed @code{500} times, but it is interrupted if the X
+coordinate of the @ref{current point} (@var{cx}) exceeds the frame width
+(@var{w}). The @ref{current point} is updated after each call to
+@vgscmd{rlineto}.
+
+@example
+moveto 0 0
+
+repeat 500 @{
+    rlineto
+        (randomg(0) * 15)
+        (randomg(0) * 20)
+
+    if (gt(cx, w)) @{
+        break
+    @}
+@}
+
+stroke
+@end example
+}
+
+@anchor{Procedures}
+@section Procedures
+
+A procedure is a name associated with a block that can be executed
+multiple times. It can take between 0 and 6 parameters.
+
+@vgscmd{proc} is used to set the parameter names and the block for a
+procedure:
+
+@example
+proc p0 @{
+    // …
+@}
+
+proc p1 param1 param2 @{
+    // …
+@}
+@end example
+
+Inside the block, the arguments can be accessed as regular variables:
+
+@example
+proc square center_x center_y side @{
+    rect
+        (center_x - side / 2) (center_y - side / 2)
+        side side
+@}
+@end example
+
+@vgscmd{call} executes the block assigned to the procedure name. It
+requires the name of the procedure, and the value for each parameter
+defined in the call to @vgscmd{proc}.
+
+@example
+call p0
+
+call p1 1 2
+
+call square (w / 2) (h / 2) (w / t)
+@end example
+
+@codeexample{
+In this example, the procedure @code{zigzag} draws multiple lines from
+the @ref{current point}.
+
+@example
+setvar len (w / 10)
+setlinewidth 5
+
+proc zigzag @{
+    repeat 10 @{
+        l len len len (-len)
+    @}
+
+    stroke
+@}
+
+setcolor #40C0FF
+M 0 60
+call zigzag
+
+setcolor #00AABB
+M 0 120
+call zigzag
+
+setcolor #20F0B7
+M 0 180
+call zigzag
+@end example
+
+The color and the Y coordinate of the starting point can be sent as
+procedure arguments:
+
+@example
+setvar len (w / 10)
+setlinewidth 5
+
+proc zigzag color y @{
+    setcolor color
+
+    M 0 y
+    repeat 10 @{
+        l len len len (-len)
+    @}
+
+    stroke
+@}
+
+call zigzag 0x40C0FFFF 60
+call zigzag 0x00AABBFF 120
+call zigzag 0x20F0B7FF 180
+@end example
+}
+
+When the procedure returns, the value of the variable for each argument
+is restored to the value it had before calling the procedure. Changes in
+other variables (with @vgscmd{setvar}, @vgscmd{getmetadata}, @vgscmd{defhsla},
+and @vgscmd{defrgba}) are preserved.
+
+@codeexample{
+In the next example, the variable @var{A} has the value @code{0} before
+calling the procedure @var{P}. During the execution of @var{P},
+@code{A} is @code{1}, but after it, @var{A} is @code{0} again.
+
+@example
+setvar A 0
+
+proc P A @{
+    print A
+@}
+
+print A
+call P 1
+print A
+@end example
+
+It writes the following messages:
+
+@verbatim
+[7:7] A = 0.000
+[4:8] A = 1.000
+[9:7] A = 0.000
+@end verbatim
+}
+
+@vgscmd{break} causes the script to leave the current procedure, similar
+to the
+@uref{https://en.wikipedia.org/wiki/Return_statement,@code{return}
+statement} in other programming languages, unless it is called within a
+@vgscmd{repeat} loop.
+
+The body of the procedure must be defined with @vgscmd{proc} @emph{before}
+using @vgscmd{call}.
+
+@codeexample{
+In this example, when the procedure @code{notyet} is called, its body
+has not yet defined, so the execution fails with the error
+@code{Missing body for procedure 'notyet'}.
+
+@example
+call notyet
+
+proc notyet @{
+    // ...
+@}
+@end example
+}
+
+A procedure can be redefined by other calls to @vgscmd{proc} with the same
+name. In such case, @vgscmd{call} invokes the last assigned block.
+
+@codeexample{
+In this example, the procedure @code{example} has two different blocks.
+
+@example
+proc example @{
+    // block1
+@}
+
+call example    // executes block1
+
+proc example @{
+    // block2
+@}
+
+call example    // executes block2
+@end example
+}
+
+@section Functions in Expressions
+
+There are some functions specific to drawvg available in @ffexprs{}.
+
+@subsection Function @code{p}
+
+@code{p(x, y)} returns the color of the pixel at coordinates
+@code{x, y}, as a @code{0xRRGGBBAA} value. This value can be assigned to
+a variable, which can be used later as the argument for @vgscmd{setcolor}.
+
+If the coordinates are outside the frame, or any of the arguments is not
+a finite number (like
+@uref{https://en.wikipedia.org/wiki/NaN,@code{NaN}}), the function
+returns @code{NaN}.
+
+The @ref{transformation matrix} is applied to the
+arguments. To use the original frame coordinates, call
+@vgscmd{resetmatrix} between @vgscmd{save} and @vgscmd{restore}:
+
+@example
+save
+resetmatrix
+setvar pixel (p(0, 0))    // top-left pixel of the frame.
+restore
+
+setcolor pixel
+@end example
+
+Bitwise operations can be used to extract individual color components:
+
+@example
+setvar pixel (p(x, y))
+
+if (not(isnan(pixel))) @{
+    setvar px_red   (pixel / 0x1000000)
+    setvar px_green (bitand(pixel / 0x10000, 0xFF))
+    setvar px_blue  (bitand(pixel / 0x100, 0xFF))
+    setvar px_alpha (bitand(pixel, 0xFF))
+@}
+@end example
+
+@subsection Function @code{pathlen}
+
+@code{pathlen(n)} computes the length of the current path, by adding the
+length of each line segment returned by
+@uref{https://www.cairographics.org/manual/cairo-Paths.html#cairo-copy-path-flat,@code{cairo_copy_path_flat}}.
+
+The function expects an argument @var{n}, as the maximum number of line
+segments to add to the length, or @code{0} to add all segments.
+
+@codeexample{
+In this example, @code{pathlen} is used to animate the stroke of a
+spiral, in a 5 seconds loop.
+
+@example
+M (w / 2) (h / 2)
+
+setvar a -1
+repeat 16 @{
+    rcurveto
+        (a * 2 / 3) 0
+        (a * 2 / 3) (a)
+        0 (a)
+
+    setvar a (-sgn(a) * (abs(a) + 10))
+@}
+
+setlinewidth 3
+setdash
+    (pathlen(0) * (1 - mod(t / 5, 1)))
+    1e6
+
+setcolor teal
+stroke
+@end example
+}
+
+@anchor{func-randomg}
+@subsection Function @code{randomg}
+
+@code{randomg(idx)} is similar to the @code{random(idx)} function,
+available in @ffexprs{}, but its state is global to the frame, instead
+of specific to each expression.
+
+To understand the difference, we need to dive into how
+@code{random(idx)} works inside a drawvg script.
+
+First, each expression in FFmpeg has a set of 10 internal variables,
+which can be written with @code{st(idx, value)}, and can be read with
+@code{ld(idx)}. @var{idx} is a value between @code{0} and @code{9}.
+These variables are initialized to @code{0}.
+
+When a drawvg script is parsed, each expression is compiled with
+@uref{https://ffmpeg.org/doxygen/8.0/eval_8h.html#ad3bf8f3330d1fd139de2ca156c313f34,@code{av_expr_parse}},
+from @uref{https://ffmpeg.org/libavutil.html,libavutil}, and these
+compiled expressions are reused for every frame. The changes in the
+internal variables (with @code{st(idx, value)}) are visible between
+frames, but they are not shared between expressions.
+
+@codeexample{
+In this example, the expression for the X coordinate updates its
+internal variable @code{0} in every frame:
+
+@example
+circle
+    (st(0, mod(ld(0) + 15, w))) // X
+    120                         // Y
+    (ld(0) + 20)                // radius
+
+fill
+@end example
+
+@code{st(idx, value)} returns the updated value, so it can be used as
+the result of the expression.
+
+The radius is not affected because its internal variable (from
+@code{ld(0)}) is not updated by the other expression.
+
+Also, note that this example is just to show how internal variables are
+kept between frames. A better approach to create this animation is to
+use the variables n or t:
+
+@example
+circle (mod(n * 15, w)) 120 20
+fill
+@end example
+}
+
+The function @code{random(idx)} returns a
+@uref{https://en.wikipedia.org/wiki/Pseudorandom_number_generator,pseudorandom}
+value between @code{0} and @code{1}. @var{idx} is the internal variable
+that is used both as the seed and to keep the state of the number
+generator.
+
+@codeexample{
+The next example uses @code{random(0)} to generate a random value for
+the center of a circle:
+
+@example
+circle
+    (random(0) * w)
+    (random(0) * h)
+    10
+
+fill
+@end example
+
+The circle in every frame is at a different position, but always on the
+diagonal line of the frame. This happens because the values for the
+coordinates X and Y are identical, since both number generators use the
+same seed.
+
+To distribute the circles over the whole frame we need different seeds
+for each expression. This can be achieved by writing a non-zero value
+(like @code{0xF0F0}) to the internal variable of one of expressions, but
+only when its value is @code{0}:
+
+@example
+circle
+    (random(0) * w)
+    (st(0, if(ld(0), ld(0), 0xF0F0)); random(0) * h)
+    10
+
+fill
+@end example
+
+This approach is only useful if we need completely different positions
+in each frame. In the next example, random values are used to distribute
+many circles over the frame, but the position is fixed. The only change
+over time is the fill color:
+
+@example
+repeat 20 @{
+    circle
+        (st(0, i + 1e5); random(0) * w)
+        (st(0, i + 1e10); random(0) * h)
+        10
+@}
+
+sethsla (t * 60) 0.5 0.5 1
+preserve fill
+
+setcolor black@@0.5
+setlinewidth 1
+stroke
+@end example
+
+This is achieved by using a precomputed state before calling @code{random(0)}.
+The variable @var{i}, updated by @vgscmd{repeat}, is needed to compute
+different states in each iteration.
+}
+
+The @code{randomg(idx)} function, which is specific to drawvg scripts,
+is similar to @code{random(idx)}, but intended to solve the previous
+problems:
+
+@itemize
+@item All frames have the same seed.
+@item The state is shared between expressions.
+@end itemize
+
+The parameter @var{idx} has two uses:
+
+@itemize
+@item
+The last two bits are the index of an internal state, so it is possible
+to have 4 different number generators.
+
+@item
+The first call to @code{randomg} with a specific index will use the
+argument as the seed for the number generator in that index.
+@end itemize
+
+In a script like this:
+
+@example
+M (randomg(0xFF1)) (randomg(0xFF0))
+l (randomg(0xAA1)) (randomg(0xFF0))
+@end example
+
+There are 4 calls to @code{randomg}:
+
+@enumerate
+@item
+The first call, with the argument @code{0xFF1}, uses the internal state
+at index @code{1} (because @code{0xFF1} modulo @code{4} is @code{1}).
+
+Since this is the first use of that index, the number generator is
+initialized with the seed @code{0xFF1}.
+
+@item
+The second call has the same behaviour: it initializes the state at
+index @code{0} with the value @code{0xFF0}.
+
+@item
+The third call has the argument @code{0xAA1}, and it uses index
+@code{1}. Since that state is already initialized (with the seed
+@code{0xFF1}), the value @code{0xAA1} is ignored, and it returns the
+next number.
+
+@end enumerate
+
+@codeexample{
+This example renders a simple rain animation, moving lines from top to
+bottom.
+
+@code{randomg} is used to distribute the lines over the frame, and to
+apply different speeds to each one.
+
+@example
+rect 0 0 w h
+setcolor midnightblue
+fill
+
+setcolor white
+
+repeat 50 @{
+    setvar offset (t * (randomg(0) + 1))
+
+    moveto
+        (mod(randomg(0) + offset / 6, 1) * w)
+        (mod(randomg(0) + offset, 1) * h)
+
+    rlineto 6 36
+
+    setlinewidth (randomg(1) / 2 + 0.2)
+    stroke
+@}
+@end example
+}
+
+@section Tracing with @code{print}
+
+It is possible to trace the execution of a drawvg script by printing the
+value of an expression, either with the @vgscmd{print} command, or with
+the print function.
+
+In both cases, the values are written to the FFmpeg log.
+
+Printing expressions may have a noticeable impact on the performance, so
+it is preferable to use it only when necessary.
+
+@subsection Function print
+
+The function @code{print(t)} writes the value of t, and returns its
+argument.
+
+@codeexample{
+Given a line line this:
+
+@example
+M (sin(2 * PI * t) * w) 0
+@end example
+
+We can see the values of @code{sin(2 * PI * t)} by surrounding it with a
+call to @code{print()}:
+
+@example
+M (print(sin(2 * PI * t)) * w) 0
+@end example
+
+Executing this script with a 1 second / 8 FPS video shows the expected
+values for the sine function.
+
+@verbatim
+$ ffmpeg \
+    -f lavfi \
+    -i 'color=r=8:d=1, drawvg=M (print(sin(2 * PI * t)) * w) 0' \
+    -f null /dev/null \
+  |& grep 'Eval @'
+
+[Eval @ 0x7f500f502d20] 0.000000
+[Eval @ 0x7f4ff784b420] 0.707107
+[Eval @ 0x7f4ff784ba20] 1.000000
+[Eval @ 0x7f4ff784c020] 0.707107
+[Eval @ 0x7f4ff784c620] 0.000000
+[Eval @ 0x7f4ff784cc20] -0.707107
+[Eval @ 0x7f4ff784d220] -1.000000
+[Eval @ 0x7f4ff784d820] -0.707107
+@end verbatim
+}
+
+@anchor{Command print}
+@subsection Command @code{print}
+
+The command @vgscmd{print} accepts an arbitrary number of arguments, and
+for each one it writes:
+
+@itemize
+@item
+The source location (line and column).
+@item
+The source code of the expression.
+@item
+The result of evaluating the expression.
+@end itemize
+
+When there are multiple expressions, they are separated by the @code{|}
+character.
+
+@codeexample{
+The next script prints the position of the @ref{current point} after the
+@vgscmd{l} command:
+
+@example
+M 10 20
+l 100 100
+print cx cy
+stroke
+@end example
+
+For each frame, it produces this output:
+
+@verbatim
+[3:7] cx = 110.000000 | [3:10] cy = 120.000000
+@end verbatim
+
+The next example prints the values of @code{random(0)}:
+
+@verbatim
+$ ffmpeg \
+    -f lavfi \
+    -i 'color=r=8:d=1, drawvg=print (random(0))' \
+    -f null /dev/null \
+  |& grep 'drawvg @'
+
+[drawvg @ 0x50a000000180] [1:7] (random(0)) = 0.229731
+[drawvg @ 0x50a000000180] [1:7] (random(0)) = 0.959813
+[drawvg @ 0x50a000000180] [1:7] (random(0)) = 0.071676
+[drawvg @ 0x50a000000180] [1:7] (random(0)) = 0.044600
+[drawvg @ 0x50a000000180] [1:7] (random(0)) = 0.134127
+[drawvg @ 0x50a000000180] [1:7] (random(0)) = 0.320513
+[drawvg @ 0x50a000000180] [1:7] (random(0)) = 0.857675
+[drawvg @ 0x50a000000180] [1:7] (random(0)) = 0.562456
+@end verbatim
+}
+
+@chapter Commands
+
+@macro signature {sig}
+@b{@code{\sig\}}
+@end macro
+
+@macro signatureimpl {sig}
+@signature{\sig\} @ @ @ --- @ref{implicit commands,@i{Can be implicit}}
+@end macro
+
+@anchor{cmd_arc}
+@section @code{arc}
+
+@signatureimpl{arc @var{xc} @var{yc} @var{radius} @var{angle1} @var{angle2}}
+
+Adds a circular arc of the given @var{radius} to the current path. The
+arc is centered at @var{xc, yc}, begins at @var{angle1} and proceeds
+in the direction of increasing angles to end at @var{angle2}.
+
+If there is a @ref{current point}, a line is added from it to the beginning of
+the arc. If this is not desired, use @vgscmd{newpath} before @vgscmd{arc} to clear
+the @ref{current point}.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-Paths.html#cairo-arc,@code{cairo_arc}}
+function for more details.
+
+@codeexample{
+@example
+arc 120 120 60 0 (3 * PI / 2)
+stroke
+@end example
+}
+
+@anchor{cmd_arcn}
+@section @code{arcn}
+
+@signatureimpl{arcn @var{xc} @var{yc} @var{radius} @var{angle1} @var{angle2}}
+
+Similar to @vgscmd{arc}, but it differs in the direction of the arc
+between the two angles.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-Paths.html#cairo-arc-negative,@code{cairo_arc_negative}}
+function for more details.
+
+@codeexample{
+In this example, both @vgscmd{arc} and @vgscmd{arcn} have the same angles,
+but they render different arcs:
+
+@example
+arc  120  90 60 (PI / 2) 0
+
+newpath
+arcn 120 150 60 (PI / 2) 0
+
+stroke
+@end example
+}
+
+@vgscmd{newpath} is needed to prevent a line between the two arcs.
+
+@anchor{cmd_break}
+@section @code{break}
+
+@signature{break}
+
+@vgscmd{break} terminates the execution of the innermost block, either a
+@vgscmd{repeat} loop or a procedure.
+
+If it is used outside of a @vgscmd{repeat} / @vgscmd{proc} block, it
+terminates the script for the current frame.
+
+@anchor{cmd_call}
+@section @code{call}
+
+@signature{call @var{name} @var{args}*}
+
+Invokes a procedure defined by @vgscmd{proc}.
+
+See the @ref{Procedures} section above for more details.
+
+@anchor{cmd_circle}
+@section @code{circle}
+
+@signatureimpl{circle @var{xc} @var{yc} @var{radius}}
+
+Adds a circle of the given @var{radius} to the current path. The circle
+is centered at @var{xc, yc}. The @ref{current point} is cleared before and
+after adding the circle.
+
+This is a convenience wrapper for @vgscmd{arc}. A call to @vgscmd{circle} is
+equivalent to:
+
+@example
+newpath
+arc xc yc radius (0) (2 * PI)
+newpath
+@end example
+
+@anchor{cmd_clip}
+@anchor{cmd_eoclip}
+@section @code{clip}, @code{eoclip}
+
+@signature{clip, eoclip}
+
+Establishes a new clip region by intersecting the current clip region
+with the current path as it would be filled by @vgscmd{fill} or
+@vgscmd{eofill}.
+
+@vgscmd{eoclip} uses the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#CAIRO-FILL-RULE-EVEN-ODD:CAPS,even--odd
+rule}. See @ref{fill rules} for more details.
+
+The path is cleared after updating the clip region, unless the
+@vgscmd{preserve} command is used before @vgscmd{clip} or @vgscmd{eoclip}.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-clip,@code{cairo_clip}}
+function for more details.
+
+@anchor{cmd_Z}
+@anchor{cmd_z}
+@anchor{cmd_closepath}
+@section @code{Z}, @code{z}, @code{closepath}
+
+@signature{Z, z, closepath}
+
+Adds a line segment to the path from the @ref{current point} to the beginning
+of the current sub-path, and closes this sub-path. The beginning is set by any
+of the @emph{move} commands (@vgscmd{M}, @vgscmd{m}, @vgscmd{moveto},
+@vgscmd{rmoveto}).
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-Paths.html#cairo-close-path,@code{cairo_close_path}}
+function for more details.
+
+@anchor{cmd_colorstop}
+@section @code{colorstop}
+
+@signatureimpl{colorstop @var{offset} @var{color}}
+
+Adds a color stop to a gradient pattern.
+
+@var{offset} is a value between @code{0} and @code{1}, and it specifies
+the location along the gradient's control vector.
+
+This command must be executed after @vgscmd{lineargrad} or
+@vgscmd{radialgrad}.
+
+Color stops can be added in any number of calls to @vgscmd{colorstop}. In
+the next example, the 3 blocks define the same gradient:
+
+@example
+// 1
+colorstop 0.0 red
+colorstop 0.5 green
+colorstop 1.0 blue
+
+// 2
+colorstop 0 red 0.5 green
+colorstop 1 blue
+
+// 3
+colorstop 0 red 0.5 green 1 blue
+@end example
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-cairo-pattern-t.html#cairo-pattern-add-color-stop-rgba,@code{cairo_pattern_add_color_stop_rgba}}
+function for more details.
+
+@codeexample{
+In this example, color stops are added in a @vgscmd{repeat} loop.
+
+@example
+lineargrad 0 0 w h
+
+repeat 6 @{
+    defhsla s (i * 60) 0.8 0.5 1
+    colorstop (i / 5) s
+@}
+
+rect 0 0 w h
+fill
+@end example
+
+It is possible to avoid transitions between color stops by repeating the
+same color in two stops:
+
+@example
+lineargrad 0 0 w h
+
+repeat 6 @{
+    defhsla s (i * 60) 0.8 0.5 1
+    colorstop (i / 5) s
+    colorstop ((i + 1) / 5) s
+@}
+
+rect 0 0 w h
+fill
+@end example
+}
+
+@anchor{cmd_C}
+@anchor{cmd_curveto}
+@section @code{C}, @code{curveto}
+
+@signatureimpl{C, curveto @var{x1} @var{y1} @var{x2} @var{y2} @var{x} @var{y}}
+
+Draw a cubic Bézier curve from the @ref{current point} to the @emph{end point}
+specified by @var{x, y}. The @emph{start control point} is specified by
+@var{x1, y1} and the @emph{end control point} is specified by @var{x2, y2}.
+
+@macro mdncubicbeziercurve
+@uref{https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/d#cubic_b%C3%A9zier_curve,Cubic Bézier Curve on MDN}
+@end macro
+
+@macro mdntutorialcurve
+@uref{https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorials/SVG_from_scratch/Paths#curve_commands,Curve Commands section of the Paths tutorial on MDN}
+@end macro
+
+The behaviour is identical to the @vgscmd{C} command in @svgpathlink{}. For more
+details, see @mdncubicbeziercurve{}, and the @mdntutorialcurve{}.
+
+@codeexample{
+@example
+moveto 20 20
+
+curveto
+    0 (h / 2)           // start control point
+    w (h / 2)           // end control point
+    (w - 20) (h - 20)   // end point
+
+stroke
+@end example
+}
+
+@anchor{cmd_c}
+@anchor{cmd_rcurveto}
+@section @code{c}, @code{rcurveto}
+
+@signatureimpl{c, rcurveto @var{dx1} @var{dy1} @var{dx2} @var{dy2} @var{dx} @var{dy}}
+
+Like @vgscmd{curveto}, but the coordinates are relative to the @ref{current
+point}.
+
+@anchor{cmd_defhsla}
+@section @code{defhsla}
+
+@signature{defhsla varname @var{h} @var{s} @var{l} @var{a}}
+
+Similar to @vgscmd{sethsla}, but instead of establishing the color for
+stroke and fill operations, the computed color is stored as a
+@code{0xRRGGBBAA} value in the variable @var{varname}.
+
+@var{varname} can then be used as a color for @vgscmd{setcolor} and
+@vgscmd{colorstop}.
+
+See @vgscmd{sethsla} for more details on how the color is computed.
+
+@anchor{cmd_defrgba}
+@section @code{defrgba}
+
+@signature{defrgba varname @var{r} @var{g} @var{b} @var{a}}
+
+Computes a color from the @emph{red}, @emph{green}, @emph{blue}, and
+@emph{alpha} components, and assigns it to the variable @var{varname}
+as a @code{0xRRGGBBAA} value.
+
+All components are values between @code{0} and @code{1}. Values outside
+that range are clamped to it.
+
+@anchor{cmd_ellipse}
+@section @code{ellipse}
+
+@signatureimpl{ellipse @var{cx} @var{cy} @var{rx} @var{ry}}
+
+Adds an ellipse to the current path. Similar to @vgscmd{circle}, but it is
+possible to use different radius for both axes.
+
+@codeexample{
+@verbatim
+ellipse 120 120 75 50
+stroke
+@end verbatim
+}
+
+@anchor{cmd_fill}
+@anchor{cmd_eofill}
+@section @code{fill}, @code{eofill}
+
+@signature{fill, eofill}
+
+Fill the current path, using the @ref{current pattern} (either
+a solid color or a gradient).
+
+@vgscmd{eofill} uses the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#CAIRO-FILL-RULE-EVEN-ODD:CAPS,even--odd
+rule}. See @ref{fill rules} for more details.
+
+The path is cleared after the operation, unless the @vgscmd{preserve}
+command is used before @vgscmd{fill} or @vgscmd{eofill}.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-fill,@code{cairo_fill}}
+function for more details.
+
+@anchor{cmd_getmetadata}
+@section @code{getmetadata}
+
+@signature{getmetadata @var{varname} @var{key}}
+
+Get the value of a metadata entry created by another filter, and assign
+it to the variable @var{varname}.
+
+If there is no metadata entry for @var{key}, or its value is not a
+number, @var{varname} is set to
+@uref{https://en.wikipedia.org/wiki/NaN,@code{NaN}}.
+
+See the @ref{Frame Metadata} section above for an
+example.
+
+@anchor{cmd_H}
+@anchor{cmd_h}
+@section @code{H}, @code{h}
+
+@signatureimpl{H, h @var{x}}
+
+Draw a horizontal line from the @ref{current point} to x.
+
+The coordinate for @vgscmd{H} is absolute, and for @vgscmd{h} it is relative
+to the @ref{current point}.
+
+@anchor{cmd_if}
+@section @code{if}
+
+@signature{if @var{condition} @{ @var{block} @}}
+
+Executes a block if the value of @var{condition} is not zero, and a
+finite number (unlike
+@uref{https://en.wikipedia.org/wiki/NaN,@code{NaN}}).
+
+See the @ref{comp-operators,Comparison and Logical Operators} section
+above for more details on how to write conditional expressions.
+
+@anchor{cmd_lineargrad}
+@section @code{lineargrad}
+
+@signature{lineargrad @var{x0} @var{y0} @var{x1} @var{y1}}
+
+Set the @ref{current pattern} to a new linear gradient, along
+the line from the coordinates @var{x0, y0} to @var{x1, y1}.
+
+This gradient can be used for stroke and fill operations.
+
+Use @vgscmd{colorstop} to set the color for each position in the gradient.
+
+@anchor{cmd_L}
+@anchor{cmd_lineto}
+@section @code{L}, @code{lineto}
+
+@signatureimpl{L, lineto @var{x} @var{y}}
+
+Draw a line from the @ref{current point} to the coordinates at @var{x, y}.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-Paths.html#cairo-line-to,@code{cairo_line_to}}
+function for more details.
+
+@anchor{cmd_l}
+@anchor{cmd_rlineto}
+@section @code{l}, @code{rlineto}
+
+@signatureimpl{l, rlineto @var{dx} @var{dy}}
+
+Like @vgscmd{lineto}, but the coordinates are relative to the @ref{current
+point}.
+
+@anchor{cmd_M}
+@anchor{cmd_moveto}
+@section @code{M}, @code{moveto}
+
+@signatureimpl{M, moveto @var{x} @var{y}}
+
+Begin a new sub-path, and set the @ref{current point} to @var{x, y}.
+
+@anchor{cmd_m}
+@anchor{cmd_rmoveto}
+@section @code{m}, @code{rmoveto}
+
+@signatureimpl{m, rmoveto @var{dx} @var{dy}}
+
+Like @vgscmd{moveto}, but the coordinates are relative to the @ref{current
+point}.
+
+@anchor{cmd_newpath}
+@section @code{newpath}
+
+@signature{newpath}
+
+Begin a new sub-path. Like @vgscmd{moveto}, but there is no
+@ref{current point} after it.
+
+@codeexample{
+In the next example, @vgscmd{newpath} is used in the path on the right to
+prevent the line connecting both arcs.
+
+@verbatim
+setlinewidth 3
+
+setcolor skyblue
+arcn 70 90 20 0 (PI)
+arc 70 150 20 0 (PI)
+stroke
+
+setcolor seagreen
+arcn 170 90 20 0 (PI)
+newpath
+arc 170 150 20 0 (PI)
+stroke
+@end verbatim
+}
+
+@anchor{cmd_preserve}
+@section @code{preserve}
+
+@signature{preserve}
+
+Indicates that the next operation to fill, stroke, or clip, must
+preserve the path, so the same path can be used in multiple operations.
+
+It has effect on these commands:
+
+@itemize
+@item @vgscmd{clip}
+@item @vgscmd{eoclip}
+@item @vgscmd{eofill}
+@item @vgscmd{fill}
+@item @vgscmd{stroke}
+@end itemize
+
+The script can contain any command between @vgscmd{preserve} and the
+associated operation. This allows modifying other properties, like the
+current color.
+
+@codeexample{
+In this example, the same path is used for both @vgscmd{fill} and
+@vgscmd{stroke}, but with different colors.
+
+@verbatim
+circle (w / 2) (h / 2) (w / 3)
+
+setcolor skyblue
+preserve fill
+
+setlinewidth 10
+setcolor seagreen
+stroke
+@end verbatim
+}
+
+@vgscmd{preserve} can be called multiple times, if the same path has to be
+used in 3 or more operations.
+
+@codeexample{
+In this example, the path created by @vgscmd{circle} is used by
+@vgscmd{fill}, @vgscmd{stroke}, and @vgscmd{clip}. After @vgscmd{clip}, the
+path is cleared.
+
+@verbatim
+circle 100 100 50
+
+preserve fill
+preserve stroke
+clip
+@end verbatim
+}
+
+@anchor{cmd_print}
+@section @code{print}
+
+@signatureimpl{print @var{expr}}
+
+Print its arguments to the FFmpeg log.
+
+See the @ref{Command print} section above for more details.
+
+@anchor{cmd_proc}
+@section @code{proc}
+
+@signature{proc @var{name} @var{params}* @{ @var{block} @}}
+
+Assign the block and the parameters for the procedure @var{name}. The
+procedure can be called multiple times with the @vgscmd{call} command.
+
+See the @ref{Procedures} section above for more details.
+
+@anchor{cmd_Q}
+@section @code{Q}
+
+@signature{Q x1 y1 @var{x} @var{y}}
+
+Draw a quadratic Bézier curve from the @ref{current point} to the @emph{end
+point} specified by @var{x, y}. The @emph{control point} is specified by
+@var{x1, y1}.
+
+@macro mdnquadbeziercurve
+@uref{https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/d#quadratic_b%C3%A9zier_curve,Quadratic Bézier curve on MDN}
+@end macro
+
+The behaviour is identical to the @code{Q} command in @svgpathlink{}. For more
+details, see @mdnquadbeziercurve{}, and the @mdntutorialcurve{}.
+
+@codeexample{
+@verbatim
+moveto 20 20
+
+Q
+    0 h                 // control point
+    (w - 20) (h - 20)   // end point
+
+stroke
+@end verbatim
+}
+
+@anchor{cmd_q}
+@section @code{q}
+
+@signature{q @var{dx1} @var{dy1} @var{dx} @var{dy}}
+
+Like @vgscmd{Q}, but the coordinates are relative to the @ref{current point}.
+
+@anchor{cmd_radialgrad}
+@section @code{radialgrad}
+
+@signature{radialgrad @var{cx0} @var{cy0} @var{radius0} @var{cx1} @var{cy1} @var{radius1}}
+
+Creates a new radial gradient between the two circles defined by
+@var{cx0 cy0 radius0} and @var{cx1 cy1 radius1}. Each set of arguments
+is the coordinates of the center and the radius.
+
+This gradient can be used for stroke and fill operations.
+
+Use @vgscmd{colorstop} to set the color for each position in the gradient.
+
+@codeexample{
+The animation in the next example shows how the two circles defined in
+the @vgscmd{radialgrad} arguments interact with each other.
+
+The red circle represent the circle for the @var{cx0 cy0 radius0}
+arguments, and the yellow circle is the one for the
+@var{cx1 cy1 radius1} arguments.
+
+@verbatim
+setvar cx0 (mod(t * 30, w))
+setvar cy0 120
+setvar radius0 20
+
+setvar cx1 120
+setvar cy1 120
+setvar radius1 70
+
+radialgrad
+    cx0 cy0 radius0
+    cx1 cy1 radius1
+
+colorstop
+    0 lightblue
+    1 darkblue
+
+// Fill the frame with the gradient.
+rect 0 0 w h
+fill
+
+// Draw inner circle.
+circle cx0 cy0 radius0
+setcolor red
+stroke
+
+// Draw outer circle.
+circle cx1 cy1 radius1
+setcolor yellow
+stroke
+@end verbatim
+}
+
+@anchor{cmd_rect}
+@section @code{rect}
+
+@signature{rect @var{x} @var{y} @var{width} @var{height}}
+
+Adds a rectangle of the given size (@var{width} @U{00D7} @var{height}), at
+position @var{x, y}, to the current path. The @ref{current point} is cleared
+before and after adding the rectangle.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-Paths.html#cairo-rectangle,@code{cairo_rectangle}}
+function for more details.
+
+@anchor{cmd_repeat}
+@section @code{repeat}
+
+@signature{repeat @var{count} @{ @var{block} @}}
+
+Executes a block the number of times indicated by @var{count}.
+
+In each iteration, the variable @var{i} is used as a
+@uref{https://en.wikipedia.org/wiki/For_loop#Loop_counters,loop
+counter}. It takes the values from @code{0} to @code{count - 1}. When
+the loop is terminated, the variable is restored to the value before
+starting the loop.
+
+If @var{count} is less than @code{1}, or it is not a finite number
+(like @uref{https://en.wikipedia.org/wiki/NaN,@code{NaN}}), the block is
+not executed.
+
+@anchor{cmd_resetclip}
+@section @code{resetclip}
+
+@signature{resetclip}
+
+Reset the current clip region to its original state, covering the whole
+frame.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-reset-clip,@code{cairo_reset_clip}}
+function for more details.
+
+@anchor{cmd_resetdash}
+@section @code{resetdash}
+
+@signature{resetdash}
+
+Disable the dash pattern to be used by @vgscmd{stroke}. This reverts any
+change made by @vgscmd{setdash} and @vgscmd{setdashoffset}.
+
+It calls
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-set-dash,@code{cairo_set_dash}}
+with @code{num_dashes} set to @code{0}.
+
+@anchor{cmd_resetmatrix}
+@section @code{resetmatrix}
+
+@signature{resetmatrix}
+
+Resets the current @ref{transformation matrix}.
+
+@anchor{cmd_restore}
+@section @code{restore}
+
+@signature{restore}
+
+Restores the state saved by a preceding call to @vgscmd{save}.
+
+For more details, see the @ref{State Stack} section above, and the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-restore,@code{cairo_restore}}
+function.
+
+@anchor{cmd_rotate}
+@section @code{rotate}
+
+@signature{rotate @var{angle}}
+
+Modifies the current @ref{transformation matrix} by rotating the user-space
+axes by @var{angle} radians.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-Transformations.html#cairo-rotate,@code{cairo_rotate}}
+function for more details.
+
+@codeexample{
+In this example:
+
+@itemize
+@item @vgscmd{scalexy} maps the coordinates to a 1x1 frame.
+@item @vgscmd{translate} put @code{0, 0} at the center of the frame.
+@item @vgscmd{rotate} rotates 45°.
+@item
+@vgscmd{resetmatrix} reverts the transformations before @vgscmd{stroke}, so the
+line width is not affected by the scale.
+@end itemize
+
+@verbatim
+scalexy w h
+translate 0.5 0.5
+rotate (PI / 4)
+rect -0.25 -0.25 0.5 0.5
+resetmatrix
+stroke
+@end verbatim
+}
+
+@anchor{cmd_roundedrect}
+@section @code{roundedrect}
+
+@signatureimpl{roundedrect @var{x} @var{y} @var{width} @var{height} @var{radius}}
+
+Like @vgscmd{rect}, but a circular arc is used for the corners.
+
+@codeexample{
+The next example shows the same rectangle, with different values for the
+corner radius.
+
+The radius is computed by multiplying @var{i} (the
+@uref{https://en.wikipedia.org/wiki/For_loop#Loop_counters,loop counter}) by
+@code{4.5}. This number is chosen to make the last shape a perfect circle.
+
+@example
+repeat 9 @{
+    roundedrect
+        (mod(i, 3) * 80 + 5)     // x
+        (floor(i / 3) * 80 + 5)  // y
+        70 70                    // size
+        (i * 4.5)                // radius
+@}
+
+stroke
+@end example
+}
+
+@anchor{cmd_save}
+@section @code{save}
+
+@signature{save}
+
+Saves a copy of the current state on an internal stack. This copy can be
+restored later with @vgscmd{restore}.
+
+For more details, see the @ref{State Stack} section above, and the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-save,@code{cairo_save}}
+function.
+
+@anchor{cmd_scale}
+@section @code{scale}
+
+@signature{scale @var{sxy}}
+
+Similar to @vgscmd{scalexy}, but the same value is used for both axes. It
+is equivalent to:
+
+@signature{scalexy @var{sxy} @var{sxy}}
+
+@anchor{cmd_scalexy}
+@section @code{scalexy}
+
+@signature{scalexy @var{sx} @var{sy}}
+
+Modifies the current @ref{transformation matrix} by scaling the X and Y
+user-space axes by @var{sx} and @var{sy} respectively.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-Transformations.html#cairo-scale,@code{cairo_scale}}
+function for more details.
+
+See @vgscmd{rotate} for an example on combining multiple transformations.
+
+@anchor{cmd_setcolor}
+@section @code{setcolor}
+
+@signature{setcolor @var{color}}
+
+Set a solid color as the @ref{current pattern} for stroke and fill operations
+
+See the @ref{Colors} section above for more details.
+
+@anchor{cmd_setdash}
+@section @code{setdash}
+
+@signatureimpl{setdash @var{length}}
+
+Sets the dash pattern to be used by @vgscmd{stroke}.
+
+Each call to @vgscmd{setdash} adds a length to the pattern, alternating
+between @emph{on} and @emph{off} portions of the stroke.
+
+After a call to @vgscmd{setdash}, @vgscmd{resetdash} is needed either to
+create a new pattern, or to discard the current one.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-set-dash,@code{cairo_set_dash}}
+function for more details.
+
+@anchor{cmd_setdashoffset}
+@section @code{setdashoffset}
+
+@signature{setdashoffset @var{offset}}
+
+Set the offset into the dash pattern at which the stroke should start.
+
+@vgscmd{setdash} must be called @emph{before} @vgscmd{setdashoffset}.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-set-dash,@code{cairo_set_dash}}
+function for more details.
+
+@codeexample{
+The next animation shows the effect of @vgscmd{setdashoffset} when its
+argument changes over time.
+
+@verbatim
+scalexy w h
+M 0.5 1
+curveto 0 0.5, 1 0.5, 0.5 0
+resetmatrix
+
+setdash 20 5 // 20 on, 5 off
+setdashoffset (t * 100)
+
+setlinewidth 20
+stroke
+@end verbatim
+}
+
+@anchor{cmd_sethsla}
+@section @code{sethsla}
+
+@signature{sethsla @var{h} @var{s} @var{l} @var{a}}
+
+Set the @ref{current pattern} to a solid color, given the @emph{hue},
+@emph{saturation}, and @emph{lightness}, and @emph{alpha} components.
+
+h is the @emph{hue}, a value between @code{0} and @code{359}. Negative
+values are clamped to @code{0}, and values greater than @code{359} are
+interpreted as modulo 360.
+
+s (@emph{saturation}), l (@emph{lightness}), and a (@emph{alpha}), are
+values between @code{0} and @code{1}.
+
+The conversion to RGB is implemented according to the
+@uref{https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB,formulae from
+Wikipedia}.
+
+@anchor{cmd_setlinecap}
+@section @code{setlinecap}
+
+@signature{setlinecap @var{cap}}
+
+Set the current line cap style, which determines the shape used to draw
+the end points of lines.
+
+@var{cap} must be one of the following names:
+
+@itemize
+@item @code{butt}
+@item @code{round}
+@item @code{square}
+@end itemize
+
+It calls to
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-set-line-cap,@code{cairo_set_line_cap}}
+to set the line cap style.
+
+@codeexample{
+This example draws 3 lines with the same length, each one with a
+different line cap style:
+
+@verbatim
+setlinewidth 40
+
+setlinecap butt
+setcolor tomato
+M 60 40 v 100 stroke
+
+setlinecap round
+setcolor seagreen
+M 120 40 v 100 stroke
+
+setlinecap square
+setcolor skyblue
+M 180 40 v 100 stroke
+
+M 20 40 H 220 m 0 100 H 20
+setcolor black@0.5
+setlinewidth 2
+stroke
+@end verbatim
+}
+
+@anchor{cmd_setlinejoin}
+@section @code{setlinejoin}
+
+@signature{setlinejoin @var{join}}
+
+Sets the current line join style, which determines the shape used to
+join two line segments.
+
+@var{join} must be one of the following names:
+
+@itemize
+@item @code{bevel}
+@item @code{miter}
+@item @code{round}
+@end itemize
+
+It calls to
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-set-line-join,@code{cairo_set_line_join}}
+to set the line join style.
+
+@codeexample{
+This example draws 3 lines with the same length, each one with a
+different line join style:
+
+@verbatim
+setlinewidth 30
+
+setlinejoin bevel
+setcolor tomato
+M 70 20 l 50 50 50 -50 stroke
+
+setlinejoin miter
+setcolor seagreen
+M 70 90 l 50 50 50 -50 stroke
+
+setlinejoin round
+setcolor skyblue
+M 70 160 l 50 50 50 -50 stroke
+@end verbatim
+}
+
+@anchor{cmd_setlinewidth}
+@section @code{setlinewidth}
+
+@signature{setlinewidth @var{width}}
+
+Set the line width for @vgscmd{stroke}.
+
+@var{width} is affected by the @ref{transformation matrix}.
+
+To specify a width that is not affected by other transformations,
+@vgscmd{resetmatrix} can be used between @vgscmd{save} / @vgscmd{restore}:
+
+@verbatim
+save
+
+resetmatrix
+setlinewidth 1
+stroke
+
+// Restore matrix after stroke.
+restore
+@end verbatim
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-set-line-width,@code{cairo_set_line_width}}
+function for more details.
+
+@anchor{cmd_setrgba}
+@section @code{setrgba}
+
+@signature{setrgba @var{r} @var{g} @var{b} @var{a}}
+
+Set the @ref{current pattern} to a solid color, given the
+@emph{red}, @emph{green}, @emph{blue}, and @emph{alpha} components.
+
+All components are values between @code{0} and @code{1}. Values outside
+that range are clamped to it.
+
+@anchor{cmd_setvar}
+@section @code{setvar}
+
+@signature{setvar @var{varname} @var{value}}
+
+Set the variable @var{varname} to @var{value}.
+
+See the @ref{User Variables} section above for more details.
+
+@anchor{cmd_stroke}
+@section @code{stroke}
+
+@signature{stroke}
+
+Strokes the current path according to the current line width, line join,
+line cap, and dash settings.
+
+The path is cleared after the operation, unless the @vgscmd{preserve}
+command is used before @vgscmd{stroke}.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-cairo-t.html#cairo-stroke,@code{cairo_stroke}}
+function for more details.
+
+@anchor{cmd_S}
+@anchor{cmd_s}
+@section @code{S}, @code{s}
+
+@signatureimpl{S, s @var{x2} @var{y2} @var{x} @var{y}}
+
+Draw a smooth cubic Bézier curve from the @ref{current point} to the @emph{end
+point} specified by @var{x, y}. The @emph{end control point} is specified by
+@var{x2, y2}.
+
+The @emph{start control point} is the reflection of the @emph{end
+control point} of the previous curve command about the @emph{current
+point}.
+
+The behaviour is identical to the @code{S} command in @svgpathlink{}. For more
+details, see @mdncubicbeziercurve{}, and the @mdntutorialcurve{}.
+
+@vgscmd{s} is like @vgscmd{S}, but the coordinates are relative to the @ref{current
+point}.
+
+@codeexample{
+@example
+M 20 120
+
+c 25 -50, 25 50, 50 0
+
+repeat 3 @{
+    s 20 50, 50 0
+@}
+
+stroke
+@end example
+}
+
+@anchor{cmd_translate}
+@section @code{translate}
+
+@signature{translate @var{tx} @var{ty}}
+
+Modifies the current @ref{transformation matrix} by
+translating the user-space origin by @var{tx, ty}.
+
+See the documentation of the
+@uref{https://www.cairographics.org/manual/cairo-Transformations.html#cairo-translate,@code{cairo_translate}}
+function for more details.
+
+@anchor{cmd_T}
+@anchor{cmd_t}
+@section @code{T}, @code{t}
+
+@signatureimpl{T, t @var{x} @var{y}}
+
+Draw a smooth quadratic Bézier curve from the @ref{current point} to the
+@emph{end point} specified by @var{x, y}.
+
+The @emph{control point} is the reflection of the @emph{control point}
+of the previous curve command about the @emph{current point}.
+
+The behaviour is identical to the @code{T} command in @svgpathlink{}. For more
+details, see @mdnquadbeziercurve{}, and the @mdntutorialcurve{}.
+
+@vgscmd{t} is like @vgscmd{T}, but the coordinates are relative to the
+@ref{current point}.
+
+@codeexample{
+@example
+M 20 120
+
+q 10 -20, 20 0
+
+repeat 9 @{
+    t 20 0
+@}
+
+stroke
+@end example
+}
+
+@anchor{cmd_V}
+@anchor{cmd_v}
+@section @code{V}, @code{v}
+
+@signatureimpl{V, v @var{y}}
+
+Draw a vertical line from the @ref{current point} to y.
+
+The coordinate for @vgscmd{V} is absolute, and for @vgscmd{v} it is relative
+to the @ref{current point}.
+
+@bye
diff --git a/doc/filters.texi b/doc/filters.texi
index 5b52fc5521..735d290a0e 100644
--- a/doc/filters.texi
+++ b/doc/filters.texi
@@ -13037,6 +13037,76 @@ For more information about libfribidi, check:
 For more information about libharfbuzz, check:
 @url{https://github.com/harfbuzz/harfbuzz}.
 
+@anchor{drawvg}
+@section drawvg
+
+Draw vector graphics on top of video frames, by executing a script written in
+a custom language called VGS (@emph{Vector Graphics Script}).
+
+The documentation for the language can be found in
+@ref{,,drawvg - Language Reference,drawvg-reference}.
+
+Graphics are rendered using the @uref{https://cairographics.org/,cario 2D
+graphics library}. The filter is enabled only if libcairo is detected when
+FFmpeg is built.
+
+@subsection Parameters
+
+Either @code{script} or @code{file} must be set.
+
+@table @option
+
+@item s, script
+Script source to draw the graphics.
+
+@item file
+Path of the file to load the script source.
+
+@end table
+
+@subsection Pixel Formats
+
+Since Cairo only supports RGB images, if the input video is something else (like
+YUV 4:2:0), before executing the script the video is converted to a format
+compatible with Cairo. Then, you have to use use either the @ref{format} filter,
+or the @code{-pix_fmt} option, to convert it to the expected format in the
+output.
+
+@subsection Examples
+
+@itemize
+@item
+Draw the outline of an ellipse.
+
+@example
+ffmpeg -i input.webm \
+    -vf 'drawvg=ellipse (w/2) (h/2) (w/3) (h/3) stroke' \
+    -pix_fmt yuv420p \
+    output.webm
+@end example
+
+@item
+
+Draw a square rotating in the middle of the frame.
+
+The script for drawvg is in a file @code{draw.vgs}:
+
+@example
+translate (w/2) (h/2)
+rotate t
+rect -100 -100 200 200
+setcolor red@@0.5
+fill
+@end example
+
+Then:
+
+@example
+ffmpeg -i input.webm -vf 'drawvg=file=draw.vgs,format=yuv420p' output.webm
+@end example
+
+@end itemize
+
 @section edgedetect
 
 Detect and draw edges. The filter uses the Canny Edge Detection algorithm.
diff --git a/libavfilter/Makefile b/libavfilter/Makefile
index bd3f6da27d..43f38941e8 100644
--- a/libavfilter/Makefile
+++ b/libavfilter/Makefile
@@ -298,6 +298,7 @@ OBJS-$(CONFIG_DRAWBOX_FILTER)                += vf_drawbox.o
 OBJS-$(CONFIG_DRAWGRAPH_FILTER)              += f_drawgraph.o
 OBJS-$(CONFIG_DRAWGRID_FILTER)               += vf_drawbox.o
 OBJS-$(CONFIG_DRAWTEXT_FILTER)               += vf_drawtext.o textutils.o
+OBJS-$(CONFIG_DRAWVG_FILTER)                 += vf_drawvg.o textutils.o
 OBJS-$(CONFIG_EDGEDETECT_FILTER)             += vf_edgedetect.o edge_common.o
 OBJS-$(CONFIG_ELBG_FILTER)                   += vf_elbg.o
 OBJS-$(CONFIG_ENTROPY_FILTER)                += vf_entropy.o
@@ -680,6 +681,10 @@ SKIPHEADERS-$(CONFIG_VULKAN)                 += vulkan_filter.h
 TOOLS     = graph2dot
 TESTPROGS = drawutils filtfmts formats integral
 
+ifdef CONFIG_DRAWVG_FILTER
+TESTPROGS += drawvg
+endif
+
 TOOLS-$(CONFIG_LIBZMQ) += zmqsend
 
 clean::
diff --git a/libavfilter/allfilters.c b/libavfilter/allfilters.c
index 3ac1502254..eb3fd2bc5a 100644
--- a/libavfilter/allfilters.c
+++ b/libavfilter/allfilters.c
@@ -272,6 +272,7 @@ extern const FFFilter ff_vf_drawbox;
 extern const FFFilter ff_vf_drawgraph;
 extern const FFFilter ff_vf_drawgrid;
 extern const FFFilter ff_vf_drawtext;
+extern const FFFilter ff_vf_drawvg;
 extern const FFFilter ff_vf_edgedetect;
 extern const FFFilter ff_vf_elbg;
 extern const FFFilter ff_vf_entropy;
diff --git a/libavfilter/tests/.gitignore b/libavfilter/tests/.gitignore
index db482cd49b..1801805573 100644
--- a/libavfilter/tests/.gitignore
+++ b/libavfilter/tests/.gitignore
@@ -7,6 +7,7 @@
 /dnn-layer-avgpool
 /dnn-layer-dense
 /drawutils
+/drawvg
 /filtfmts
 /formats
 /integral
diff --git a/libavfilter/tests/drawvg.c b/libavfilter/tests/drawvg.c
new file mode 100644
index 0000000000..1d07144140
--- /dev/null
+++ b/libavfilter/tests/drawvg.c
@@ -0,0 +1,347 @@
+/*
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include <cairo.h>
+#include <stdarg.h>
+#include <stdio.h>
+
+#include "libavutil/log.h"
+#include "libavutil/pixdesc.h"
+
+static void mock_av_log(void *ptr, int level, const char *fmt, va_list vl) {
+    printf("av_log[%d]: ", level);
+    vprintf(fmt, vl);
+}
+
+#include "libavfilter/vf_drawvg.c"
+
+// Mock for cairo functions.
+//
+// `MOCK_FN_n` macros define wrappers for functions that only receive `n`
+// arguments of type `double`.
+//
+// `MOCK_FN_I` macro wrap a function that receives a single integer value.
+
+static double current_point_x;
+static double current_point_y;
+
+static void update_current_point(const char *func, double x, double y) {
+    // Update current point only if the function name contains `_to`.
+    if (strstr(func, "_to") == NULL) {
+        return;
+    }
+
+    if (strstr(func, "_rel_") == NULL) {
+        current_point_x = x;
+        current_point_y = y;
+    } else {
+        current_point_x += x;
+        current_point_y += y;
+    }
+}
+
+#define MOCK_FN_0(func)      \
+    void func(cairo_t* cr) { \
+        puts(#func);         \
+    }
+
+#define MOCK_FN_1(func)                 \
+    void func(cairo_t* cr, double a0) { \
+        printf(#func " %.1f\n", a0);      \
+    }
+
+#define MOCK_FN_2(func)                            \
+    void func(cairo_t* cr, double a0, double a1) { \
+        update_current_point(#func, a0, a1);       \
+        printf(#func " %.1f %.1f\n", a0, a1);          \
+    }
+
+#define MOCK_FN_4(func)                                                  \
+    void func(cairo_t* cr, double a0, double a1, double a2, double a3) { \
+        printf(#func " %.1f %.1f %.1f %.1f\n", a0, a1, a2, a3);                  \
+    }
+
+#define MOCK_FN_5(func)                                                             \
+    void func(cairo_t* cr, double a0, double a1, double a2, double a3, double a4) { \
+        printf(#func " %.1f %.1f %.1f %.1f %.1f\n", a0, a1, a2, a3, a4);                      \
+    }
+
+#define MOCK_FN_6(func)                                                                        \
+    void func(cairo_t* cr, double a0, double a1, double a2, double a3, double a4, double a5) { \
+        update_current_point(#func, a4, a5);                                                   \
+        printf(#func " %.1f %.1f %.1f %.1f %.1f %.1f\n", a0, a1, a2, a3, a4, a5);                          \
+    }
+
+#define MOCK_FN_I(func, type)          \
+    void func(cairo_t* cr, type i) {   \
+        printf(#func " %d\n", (int)i); \
+    }
+
+MOCK_FN_5(cairo_arc);
+MOCK_FN_0(cairo_clip);
+MOCK_FN_0(cairo_clip_preserve);
+MOCK_FN_0(cairo_close_path);
+MOCK_FN_6(cairo_curve_to);
+MOCK_FN_0(cairo_fill);
+MOCK_FN_0(cairo_fill_preserve);
+MOCK_FN_0(cairo_identity_matrix);
+MOCK_FN_2(cairo_line_to);
+MOCK_FN_2(cairo_move_to);
+MOCK_FN_0(cairo_new_path);
+MOCK_FN_0(cairo_new_sub_path);
+MOCK_FN_4(cairo_rectangle);
+MOCK_FN_6(cairo_rel_curve_to);
+MOCK_FN_2(cairo_rel_line_to);
+MOCK_FN_2(cairo_rel_move_to);
+MOCK_FN_0(cairo_reset_clip);
+MOCK_FN_0(cairo_restore);
+MOCK_FN_1(cairo_rotate);
+MOCK_FN_0(cairo_save);
+MOCK_FN_2(cairo_scale);
+MOCK_FN_I(cairo_set_fill_rule, cairo_fill_rule_t);
+MOCK_FN_1(cairo_set_font_size);
+MOCK_FN_I(cairo_set_line_cap, cairo_line_cap_t);
+MOCK_FN_I(cairo_set_line_join, cairo_line_join_t);
+MOCK_FN_1(cairo_set_line_width);
+MOCK_FN_1(cairo_set_miter_limit);
+MOCK_FN_4(cairo_set_source_rgba);
+MOCK_FN_0(cairo_stroke);
+MOCK_FN_0(cairo_stroke_preserve);
+MOCK_FN_2(cairo_translate);
+
+cairo_bool_t cairo_get_dash_count(cairo_t *cr) {
+    return 1;
+}
+
+cairo_status_t cairo_status(cairo_t *cr) {
+    return CAIRO_STATUS_SUCCESS;
+}
+
+void cairo_get_dash(cairo_t *cr, double *dashes, double *offset) {
+    // Return a dummy value to verify that it is included in
+    // the next call to `cairo_set_dash`.
+    *dashes = -1;
+
+    if (offset)
+        *offset = -2;
+}
+
+void cairo_set_dash(cairo_t *cr, const double *dashes, int num_dashes, double offset) {
+    printf("%s [", __func__);
+    for (int i = 0; i < num_dashes; i++)
+        printf(" %.1f", dashes[i]);
+    printf(" ] %.1f\n", offset);
+}
+
+cairo_bool_t cairo_has_current_point(cairo_t *cr) {
+    return 1;
+}
+
+void cairo_get_current_point(cairo_t *cr, double *x, double *y) {
+    *x = current_point_x;
+    *y = current_point_y;
+}
+
+void cairo_set_source (cairo_t *cr, cairo_pattern_t *source) {
+    int count;
+    double r, g, b, a;
+    double x0, y0, x1, y1, r0, r1;
+
+    printf("%s", __func__);
+
+#define PRINT_COLOR(prefix) \
+    printf(prefix "#%02x%02x%02x%02x", (int)(r*255), (int)(g*255), (int)(b*255), (int)(a*255))
+
+    switch (cairo_pattern_get_type(source)) {
+    case CAIRO_PATTERN_TYPE_SOLID:
+        cairo_pattern_get_rgba(source, &r, &g, &b, &a);
+        PRINT_COLOR(" ");
+        break;
+
+    case CAIRO_PATTERN_TYPE_LINEAR:
+        cairo_pattern_get_linear_points(source, &x0, &y0, &x1, &y1);
+        printf(" lineargrad(%.1f %.1f %.1f %.1f)", x0, y0, x1, y1);
+        break;
+
+    case CAIRO_PATTERN_TYPE_RADIAL:
+        cairo_pattern_get_radial_circles(source, &x0, &y0, &r0, &x1, &y1, &r1);
+        printf(" radialgrad(%.1f %.1f %.1f %.1f %.1f %.1f)", x0, y0, r0, x1, y1, r1);
+        break;
+    }
+
+    if (cairo_pattern_get_color_stop_count(source, &count) == CAIRO_STATUS_SUCCESS) {
+        for (int i = 0; i < count; i++) {
+            cairo_pattern_get_color_stop_rgba(source, i, &x0, &r, &g, &b, &a);
+            printf(" %.1f/", x0);
+            PRINT_COLOR("");
+        }
+    }
+
+    printf("\n");
+}
+
+// Verify that the `vgs_commands` array is sorted, so it can
+// be used with `bsearch(3)`.
+static void check_sorted_cmds_array(void) {
+    int failures = 0;
+
+    for (int i = 0; i < FF_ARRAY_ELEMS(vgs_commands) - 1; i++) {
+        if (vgs_comp_command_spec(&vgs_commands[i], &vgs_commands[i]) != 0) {
+            printf("%s: comparator must return 0 for item %d\n", __func__, i);
+            failures++;
+        }
+
+        if (vgs_comp_command_spec(&vgs_commands[i], &vgs_commands[i + 1]) >= 0) {
+            printf("%s: entry for '%s' must appear after '%s', at index %d\n",
+                __func__, vgs_commands[i].name, vgs_commands[i + 1].name, i);
+            failures++;
+        }
+    }
+
+    printf("%s: %d failures\n", __func__, failures);
+}
+
+// Compile and run a script.
+static void check_script(int is_file, const char* source) {
+    int ret;
+
+    AVDictionary *metadata;
+
+    struct VGSEvalState state;
+    struct VGSParser parser;
+    struct VGSProgram program;
+
+    if (is_file) {
+        uint8_t *s = NULL;
+
+        printf("\n--- %s: %s\n", __func__, av_basename(source));
+
+        ret = ff_load_textfile(NULL, source, &s, NULL);
+        if (ret != 0) {
+            printf("Failed to read %s: %d\n", source, ret);
+            return;
+        }
+
+        source = s;
+    } else {
+        printf("\n--- %s: %s\n", __func__, source);
+    }
+
+    ret = av_dict_parse_string(&metadata, "m.a=1:m.b=2", "=", ":", 0);
+    av_assert0(ret == 0);
+
+    vgs_parser_init(&parser, source);
+
+    ret = vgs_parse(NULL, &parser, &program, 0);
+
+    vgs_eval_state_init(&state, &program, NULL, NULL);
+
+    for (int i = 0; i < VAR_COUNT; i++)
+        state.vars[i] = 1 << i;
+
+    current_point_x = 0;
+    current_point_y = 0;
+
+    vgs_parser_free(&parser);
+
+    if (ret != 0) {
+        printf("%s: vgs_parse = %d\n", __func__, ret);
+        goto exit;
+    }
+
+    state.metadata = metadata;
+
+    ret = vgs_eval(&state, &program);
+    vgs_eval_state_free(&state);
+
+    if (ret != 0)
+        printf("%s: vgs_eval = %d\n", __func__, ret);
+
+exit:
+    av_dict_free(&metadata);
+
+    if (is_file)
+        av_free((void*)source);
+
+    vgs_free(&program);
+}
+
+int main(int argc, const char **argv)
+{
+    char buf[512];
+
+    av_log_set_callback(mock_av_log);
+
+    check_sorted_cmds_array();
+
+    for (int i = 1; i < argc; i++)
+        check_script(1, argv[i]);
+
+    // Detect unclosed expressions.
+    check_script(0, "M 0 (1*(t+1)");
+
+    // Invalid command.
+    check_script(0, "save invalid 1 2");
+
+    // Invalid constant.
+    check_script(0, "setlinecap unknown m 10 20");
+
+    // Missing arguments.
+    check_script(0, "M 0 1 2");
+
+    // Invalid variable names.
+    check_script(0, "setvar ba^d 0");
+
+    // Reserved names.
+    check_script(0, "setvar cx 0");
+
+    // Max number of user variables.
+    memset(buf, 0, sizeof(buf));
+    for (int i = 0; i < USER_VAR_COUNT; i++) {
+        av_strlcatf(buf, sizeof(buf), " setvar v%d %d", i, i);
+    }
+    av_strlcatf(buf, sizeof(buf), " M (v0) (v%d) 1 (unknown_var)", USER_VAR_COUNT - 1);
+    check_script(0, buf);
+
+    // Too many variables.
+    memset(buf, 0, sizeof(buf));
+    for (int i = 0; i < USER_VAR_COUNT + 1; i++) {
+        av_strlcatf(buf, sizeof(buf), " setvar v%d %d", i + 1, i);
+    }
+    check_script(0, buf);
+
+    // Invalid procedure names.
+    check_script(0, "call a");
+    check_script(0, "proc a { call b } call a");
+
+    // Invalid arguments list.
+    check_script(0, "proc p0 a1 a2 a3 a4 a5 a6 a7 a8 { break }");
+    check_script(0, "proc p0 a1 a2 { break } call p0 break");
+    check_script(0, "proc p0 a1 a2 { break } call p0 1 2 3");
+
+    // Long expressions.
+    memset(buf, 0, sizeof(buf));
+    strncat(buf, "M 0 (1", sizeof(buf) - 1);
+    for (int i = 0; i < 100; i++) {
+        strncat(buf, " + n", sizeof(buf) - 1);
+    }
+    strncat(buf, ")", sizeof(buf) - 1);
+    check_script(0, buf);
+
+    return 0;
+}
diff --git a/libavfilter/version.h b/libavfilter/version.h
index ba8a6fdab2..77f38cb9b4 100644
--- a/libavfilter/version.h
+++ b/libavfilter/version.h
@@ -31,7 +31,7 @@
 
 #include "version_major.h"
 
-#define LIBAVFILTER_VERSION_MINOR   8
+#define LIBAVFILTER_VERSION_MINOR   9
 #define LIBAVFILTER_VERSION_MICRO 100
 
 
diff --git a/libavfilter/vf_drawvg.c b/libavfilter/vf_drawvg.c
new file mode 100644
index 0000000000..032cfb1c35
--- /dev/null
+++ b/libavfilter/vf_drawvg.c
@@ -0,0 +1,2699 @@
+/*
+ * This file is part of FFmpeg.
+ *
+ * FFmpeg is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * FFmpeg is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with FFmpeg; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+/**
+ * @file
+ *
+ * drawvg filter, draw vector graphics with cairo.
+ *
+ * This file contains the parser and the interpreter for VGS, and the
+ * AVClass definitions for the drawvg filter.
+ */
+
+#include <cairo.h>
+
+#include "libavutil/avassert.h"
+#include "libavutil/avstring.h"
+#include "libavutil/bswap.h"
+#include "libavutil/eval.h"
+#include "libavutil/internal.h"
+#include "libavutil/macros.h"
+#include "libavutil/mem.h"
+#include "libavutil/opt.h"
+#include "libavutil/pixdesc.h"
+#include "libavutil/sfc64.h"
+
+#include "avfilter.h"
+#include "filters.h"
+#include "textutils.h"
+#include "video.h"
+
+/*
+ * == AVExpr Integration ==
+ *
+ * Definitions to use variables and functions in the expressions from
+ * `av_expr_*` functions.
+ *
+ * For user-variables, created with commands like `setvar` or `defhsla`,
+ * the VGS parser updates a copy of the `vgs_default_vars` array. The
+ * first user-variable is stored in the slot for `VAR_U0`.
+ */
+
+enum {
+    VAR_N,          ///< Frame number.
+    VAR_T,          ///< Timestamp in seconds.
+    VAR_TS,         ///< Time in seconds of the first frame.
+    VAR_W,          ///< Frame width.
+    VAR_H,          ///< Frame height.
+    VAR_DURATION,   ///< Frame duration.
+    VAR_CX,         ///< X coordinate for current point.
+    VAR_CY,         ///< Y coordinate for current point.
+    VAR_I,          ///< Loop counter, to use with `repeat {}`.
+    VAR_U0,         ///< User variables.
+};
+
+/// Number of user variables that can be created with `setvar`.
+///
+/// It is possible to allow any number of variables, but this
+/// approach simplifies the implementation, and 20 variables
+/// is more than enough for the expected use of this filter.
+#define USER_VAR_COUNT 20
+
+/// Total number of variables (default- and user-variables).
+#define VAR_COUNT (VAR_U0 + USER_VAR_COUNT)
+
+static const char *const vgs_default_vars[] = {
+    "n",
+    "t",
+    "ts",
+    "w",
+    "h",
+    "duration",
+    "cx",
+    "cy",
+    "i",
+    NULL, // User variables. Name is assigned by commands like `setvar`.
+};
+
+// Functions used in expressions.
+
+static const char *const vgs_func1_names[] = {
+    "pathlen",
+    "randomg",
+    NULL,
+};
+
+static double vgs_fn_pathlen(void *, double);
+static double vgs_fn_randomg(void *, double);
+
+static double (*const vgs_func1_impls[])(void *, double) = {
+    vgs_fn_pathlen,
+    vgs_fn_randomg,
+    NULL,
+};
+
+static const char *const vgs_func2_names[] = {
+    "p",
+    NULL,
+};
+
+static double vgs_fn_p(void *, double, double);
+
+static double (*const vgs_func2_impls[])(void *, double, double) = {
+    vgs_fn_p,
+    NULL,
+};
+
+/*
+ * == Command Declarations ==
+ *
+ * Each command is defined by an opcode (used later by the interpreter), a name,
+ * and a set of parameters.
+ *
+ * Inspired by SVG, some commands can be repeated when the next token after the
+ * last parameter is a numeric value (for example, `L 1 2 3 4` is equivalent to
+ * `L 1 2 L 3 4`). In these commands, the last parameter is `PARAM_MAY_REPEAT`.
+ */
+
+enum VGSCommand {
+    CMD_ARC = 1,                ///<  arc (cx cy radius angle1 angle2)
+    CMD_ARC_NEG,                ///<  arcn (cx cy radius angle1 angle2)
+    CMD_BREAK,                  ///<  break
+    CMD_CIRCLE,                 ///<  circle (cx cy radius)
+    CMD_CLIP,                   ///<  clip
+    CMD_CLIP_EO,                ///<  eoclip
+    CMD_CLOSE_PATH,             ///<  Z, z, closepath
+    CMD_COLOR_STOP,             ///<  colorstop (offset color)
+    CMD_CURVE_TO,               ///<  C, curveto (x1 y1 x2 y2 x y)
+    CMD_DEF_HSLA,               ///<  defhsla (varname h s l a)
+    CMD_DEF_RGBA,               ///<  defrgba (varname r g b a)
+    CMD_CURVE_TO_REL,           ///<  c, rcurveto (dx1 dy1 dx2 dy2 dx dy)
+    CMD_ELLIPSE,                ///<  ellipse (cx cy rx ry)
+    CMD_FILL,                   ///<  fill
+    CMD_FILL_EO,                ///<  eofill
+    CMD_GET_METADATA,           ///<  getmetadata varname key
+    CMD_HORZ,                   ///<  H (x)
+    CMD_HORZ_REL,               ///<  h (dx)
+    CMD_IF,                     ///<  if (condition) { subprogram }
+    CMD_LINEAR_GRAD,            ///<  lineargrad (x0 y0 x1 y1)
+    CMD_LINE_TO,                ///<  L, lineto (x y)
+    CMD_LINE_TO_REL,            ///<  l, rlineto (dx dy)
+    CMD_MOVE_TO,                ///<  M, moveto (x y)
+    CMD_MOVE_TO_REL,            ///<  m, rmoveto (dx dy)
+    CMD_NEW_PATH,               ///<  newpath
+    CMD_PRESERVE,               ///<  preserve
+    CMD_PRINT,                  ///<  print (expr)*
+    CMD_PROC_ASSIGN,            ///<  proc name varnames* { subprogram }
+    CMD_PROC_CALL,              ///<  call name (expr)*
+    CMD_Q_CURVE_TO,             ///<  Q (x1 y1 x y)
+    CMD_Q_CURVE_TO_REL,         ///<  q (dx1 dy1 dx dy)
+    CMD_RADIAL_GRAD,            ///<  radialgrad (cx0 cy0 radius0 cx1 cy1 radius1)
+    CMD_RECT,                   ///<  rect (x y width height)
+    CMD_REPEAT,                 ///<  repeat (count) { subprogram }
+    CMD_RESET_CLIP,             ///<  resetclip
+    CMD_RESET_DASH,             ///<  resetdash
+    CMD_RESET_MATRIX,           ///<  resetmatrix
+    CMD_RESTORE,                ///<  restore
+    CMD_ROTATE,                 ///<  rotate (angle)
+    CMD_ROUNDEDRECT,            ///<  roundedrect (x y width height radius)
+    CMD_SAVE,                   ///<  save
+    CMD_SCALE,                  ///<  scale (s)
+    CMD_SCALEXY,                ///<  scalexy (sx sy)
+    CMD_SET_COLOR,              ///<  setcolor (color)
+    CMD_SET_DASH,               ///<  setdash (length)
+    CMD_SET_DASH_OFFSET,        ///<  setdashoffset (offset)
+    CMD_SET_HSLA,               ///<  sethsla (h s l a)
+    CMD_SET_LINE_CAP,           ///<  setlinecap (cap)
+    CMD_SET_LINE_JOIN,          ///<  setlinejoin (join)
+    CMD_SET_LINE_WIDTH,         ///<  setlinewidth (width)
+    CMD_SET_RGBA,               ///<  setrgba (r g b a)
+    CMD_SET_VAR,                ///<  setvar (varname value)
+    CMD_STROKE,                 ///<  stroke
+    CMD_S_CURVE_TO,             ///<  S (x2 y2 x y)
+    CMD_S_CURVE_TO_REL,         ///<  s (dx2 dy2 dx dy)
+    CMD_TRANSLATE,              ///<  translate (tx ty)
+    CMD_T_CURVE_TO,             ///<  T (x y)
+    CMD_T_CURVE_TO_REL,         ///<  t (dx dy)
+    CMD_VERT,                   ///<  V (y)
+    CMD_VERT_REL,               ///<  v (dy)
+};
+
+/// Constants for some commands, like `setlinejoin`.
+struct VGSConstant {
+    const char* name;
+    int value;
+};
+
+static const struct VGSConstant vgs_consts_line_cap[] = {
+    { "butt", CAIRO_LINE_CAP_BUTT },
+    { "round", CAIRO_LINE_CAP_ROUND },
+    { "square", CAIRO_LINE_CAP_SQUARE },
+    { NULL, 0 },
+};
+
+static const struct VGSConstant vgs_consts_line_join[] = {
+    { "bevel", CAIRO_LINE_JOIN_BEVEL },
+    { "miter", CAIRO_LINE_JOIN_MITER },
+    { "round", CAIRO_LINE_JOIN_ROUND },
+    { NULL, 0 },
+};
+
+struct VGSParameter {
+    enum {
+        PARAM_COLOR = 1,
+        PARAM_CONSTANT,
+        PARAM_END,
+        PARAM_MAY_REPEAT,
+        PARAM_NUMERIC,
+        PARAM_NUMERIC_METADATA,
+        PARAM_PROC_ARGS,
+        PARAM_PROC_NAME,
+        PARAM_PROC_PARAMS,
+        PARAM_RAW_IDENT,
+        PARAM_SUBPROGRAM,
+        PARAM_VARIADIC,
+        PARAM_VAR_NAME,
+    } type;
+
+    const struct VGSConstant *constants; ///< Array for PARAM_CONSTANT.
+};
+
+// Max number of parameters for a command.
+#define MAX_COMMAND_PARAMS 8
+
+// Max number of arguments when calling a procedure. Subtract 2 to
+// `MAX_COMMAND_PARAMS` because the call to `proc` needs 2 arguments
+// (the procedure name and its body). The rest can be variable names
+// for the arguments.
+#define MAX_PROC_ARGS (MAX_COMMAND_PARAMS - 2)
+
+// Definition of each command.
+
+struct VGSCommandSpec {
+    const char* name;
+    enum VGSCommand cmd;
+    const struct VGSParameter *params;
+};
+
+// Parameter lists.
+#define PARAMS(...) (const struct VGSParameter[]){ __VA_ARGS__ }
+#define L(...) PARAMS(__VA_ARGS__, { PARAM_END })
+#define R(...) PARAMS(__VA_ARGS__, { PARAM_MAY_REPEAT })
+#define NONE   PARAMS({ PARAM_END })
+
+// Common parameter types.
+#define N { PARAM_NUMERIC }
+#define V { PARAM_VAR_NAME }
+#define P { PARAM_SUBPROGRAM }
+#define C(c) { PARAM_CONSTANT, .constants = c }
+
+// Declarations table.
+//
+// The array must be sorted by `name` in ascending order.
+static const struct VGSCommandSpec vgs_commands[] = {
+    { "C",              CMD_CURVE_TO,         R(N, N, N, N, N, N) },
+    { "H",              CMD_HORZ,             R(N) },
+    { "L",              CMD_LINE_TO,          R(N, N) },
+    { "M",              CMD_MOVE_TO,          R(N, N) },
+    { "Q",              CMD_Q_CURVE_TO,       R(N, N, N, N) },
+    { "S",              CMD_S_CURVE_TO,       R(N, N, N, N) },
+    { "T",              CMD_T_CURVE_TO,       R(N, N) },
+    { "V",              CMD_VERT,             R(N) },
+    { "Z",              CMD_CLOSE_PATH,       NONE },
+    { "arc",            CMD_ARC,              R(N, N, N, N, N) },
+    { "arcn",           CMD_ARC_NEG,          R(N, N, N, N, N) },
+    { "break",          CMD_BREAK,            NONE },
+    { "c",              CMD_CURVE_TO_REL,     R(N, N, N, N, N, N) },
+    { "call",           CMD_PROC_CALL,        L({ PARAM_PROC_NAME }, { PARAM_PROC_ARGS }) },
+    { "circle",         CMD_CIRCLE,           R(N, N, N) },
+    { "clip",           CMD_CLIP,             NONE },
+    { "closepath",      CMD_CLOSE_PATH,       NONE },
+    { "colorstop",      CMD_COLOR_STOP,       R(N, { PARAM_COLOR }) },
+    { "curveto",        CMD_CURVE_TO,         R(N, N, N, N, N, N) },
+    { "defhsla",        CMD_DEF_HSLA,         L(V, N, N, N, N) },
+    { "defrgba",        CMD_DEF_RGBA,         L(V, N, N, N, N) },
+    { "ellipse",        CMD_ELLIPSE,          R(N, N, N, N) },
+    { "eoclip",         CMD_CLIP_EO,          NONE },
+    { "eofill",         CMD_FILL_EO,          NONE },
+    { "fill",           CMD_FILL,             NONE },
+    { "getmetadata",    CMD_GET_METADATA,     L(V, { PARAM_RAW_IDENT }) },
+    { "h",              CMD_HORZ_REL,         R(N) },
+    { "if",             CMD_IF,               L(N, P) },
+    { "l",              CMD_LINE_TO_REL,      R(N, N) },
+    { "lineargrad",     CMD_LINEAR_GRAD,      L(N, N, N, N) },
+    { "lineto",         CMD_LINE_TO,          R(N, N) },
+    { "m",              CMD_MOVE_TO_REL,      R(N, N) },
+    { "moveto",         CMD_MOVE_TO,          R(N, N) },
+    { "newpath",        CMD_NEW_PATH,         NONE },
+    { "preserve",       CMD_PRESERVE,         NONE },
+    { "print",          CMD_PRINT,            L({ PARAM_NUMERIC_METADATA }, { PARAM_VARIADIC }) },
+    { "proc",           CMD_PROC_ASSIGN,      L({ PARAM_PROC_NAME }, { PARAM_PROC_PARAMS }, P) },
+    { "q",              CMD_Q_CURVE_TO_REL,   R(N, N, N, N) },
+    { "radialgrad",     CMD_RADIAL_GRAD,      L(N, N, N, N, N, N) },
+    { "rcurveto",       CMD_CURVE_TO_REL,     R(N, N, N, N, N, N) },
+    { "rect",           CMD_RECT,             R(N, N, N, N) },
+    { "repeat",         CMD_REPEAT,           L(N, P) },
+    { "resetclip",      CMD_RESET_CLIP,       NONE },
+    { "resetdash",      CMD_RESET_DASH,       NONE },
+    { "resetmatrix",    CMD_RESET_MATRIX,     NONE },
+    { "restore",        CMD_RESTORE,          NONE },
+    { "rlineto",        CMD_LINE_TO_REL,      R(N, N) },
+    { "rmoveto",        CMD_MOVE_TO_REL,      R(N, N) },
+    { "rotate",         CMD_ROTATE,           L(N) },
+    { "roundedrect",    CMD_ROUNDEDRECT,      R(N, N, N, N, N) },
+    { "s",              CMD_S_CURVE_TO_REL,   R(N, N, N, N) },
+    { "save",           CMD_SAVE,             NONE },
+    { "scale",          CMD_SCALE,            L(N) },
+    { "scalexy",        CMD_SCALEXY,          L(N, N) },
+    { "setcolor",       CMD_SET_COLOR,        L({ PARAM_COLOR }) },
+    { "setdash",        CMD_SET_DASH,         R(N) },
+    { "setdashoffset",  CMD_SET_DASH_OFFSET,  R(N) },
+    { "sethsla",        CMD_SET_HSLA,         L(N, N, N, N) },
+    { "setlinecap",     CMD_SET_LINE_CAP,     L(C(vgs_consts_line_cap)) },
+    { "setlinejoin",    CMD_SET_LINE_JOIN,    L(C(vgs_consts_line_join)) },
+    { "setlinewidth",   CMD_SET_LINE_WIDTH,   L(N) },
+    { "setrgba",        CMD_SET_RGBA,         L(N, N, N, N) },
+    { "setvar",         CMD_SET_VAR,          L(V, N) },
+    { "stroke",         CMD_STROKE,           NONE },
+    { "t",              CMD_T_CURVE_TO_REL,   R(N, N) },
+    { "translate",      CMD_TRANSLATE,        L(N, N) },
+    { "v",              CMD_VERT_REL,         R(N) },
+    { "z",              CMD_CLOSE_PATH,       NONE },
+};
+
+#undef C
+#undef L
+#undef N
+#undef NONE
+#undef PARAMS
+#undef R
+
+/// Comparator for `VGSCommandDecl`, to be used with `bsearch(3)`.
+static int vgs_comp_command_spec(const void *cs1, const void *cs2) {
+    return strcmp(
+        ((const struct VGSCommandSpec*)cs1)->name,
+        ((const struct VGSCommandSpec*)cs2)->name
+    );
+}
+
+/// Return the specs for the given command, or `NULL` if the name is not valid.
+///
+/// The implementation assumes that `vgs_commands` is sorted by `name`.
+static const struct VGSCommandSpec* vgs_get_command(const char *name, size_t length) {
+    char bufname[64];
+    struct VGSCommandSpec key = { .name = bufname };
+
+    if (length >= sizeof(bufname))
+        return NULL;
+
+    memcpy(bufname, name, length);
+    bufname[length] = '\0';
+
+    return bsearch(
+        &key,
+        vgs_commands,
+        FF_ARRAY_ELEMS(vgs_commands),
+        sizeof(vgs_commands[0]),
+        vgs_comp_command_spec
+    );
+}
+
+/// Return `1` if the command changes the current path in the cairo context.
+static int vgs_cmd_change_path(enum VGSCommand cmd) {
+    switch (cmd) {
+    case CMD_BREAK:
+    case CMD_COLOR_STOP:
+    case CMD_DEF_HSLA:
+    case CMD_DEF_RGBA:
+    case CMD_GET_METADATA:
+    case CMD_IF:
+    case CMD_LINEAR_GRAD:
+    case CMD_PRINT:
+    case CMD_PROC_ASSIGN:
+    case CMD_PROC_CALL:
+    case CMD_RADIAL_GRAD:
+    case CMD_REPEAT:
+    case CMD_RESET_DASH:
+    case CMD_RESET_MATRIX:
+    case CMD_SET_COLOR:
+    case CMD_SET_DASH:
+    case CMD_SET_DASH_OFFSET:
+    case CMD_SET_HSLA:
+    case CMD_SET_LINE_CAP:
+    case CMD_SET_LINE_JOIN:
+    case CMD_SET_LINE_WIDTH:
+    case CMD_SET_RGBA:
+    case CMD_SET_VAR:
+        return 0;
+
+    default:
+        return 1;
+    }
+}
+
+/*
+ * == VGS Parser ==
+ *
+ * The lexer determines the token kind by reading the first character after a
+ * delimiter (any of " \n\t\r,").
+ *
+ * The output of the parser is an instance of `VGSProgram`. It is a list of
+ * statements, and each statement is a command opcode and its arguments. This
+ * instance is created on filter initialization, and reused for every frame.
+ *
+ * User-variables are stored in an array initialized with a copy of
+ * `vgs_default_vars`.
+ *
+ * Blocks (the body for procedures, `if`, and `repeat`) are stored as nested
+ * `VGSProgram` instances.
+ *
+ * The source is assumed to be ASCII. If it contains multibyte chars, each
+ * byte is treated as an individual character. This is only relevant when the
+ * parser must report the location of a syntax error.
+ *
+ * There is no error recovery. The first invalid token will stop the parser.
+ */
+
+struct VGSParser {
+    const char* source;
+    size_t cursor;
+
+    const char **proc_names;
+    int proc_names_count;
+
+    // Store the variable names for the default ones (from `vgs_default_vars`)
+    // and the variables created with `setvar`.
+    //
+    // The extra slot is needed to store the `NULL` terminator expected by
+    // `av_expr_parse`.
+    const char *var_names[VAR_COUNT + 1];
+};
+
+struct VGSParserToken {
+    enum {
+        TOKEN_EOF = 1,
+        TOKEN_EXPR,
+        TOKEN_LEFT_BRACKET,
+        TOKEN_LITERAL,
+        TOKEN_RIGHT_BRACKET,
+        TOKEN_WORD,
+    } type;
+
+    const char *lexeme;
+    size_t position;
+    size_t length;
+};
+
+/// Check if `token` is the value of `str`.
+static int vgs_token_is_string(const struct VGSParserToken *token, const char *str) {
+    return strncmp(str, token->lexeme, token->length) == 0
+        && str[token->length] == '\0';
+}
+
+/// Compute the line/column numbers of the given token.
+static void vgs_token_span(
+    const struct VGSParser *parser,
+    const struct VGSParserToken *token,
+    size_t *line,
+    size_t *column
+) {
+    const char *source = parser->source;
+
+    *line = 1;
+
+    for (;;) {
+        const char *sep = strchr(source, '\n');
+
+        if (sep == NULL || (sep - parser->source) > token->position) {
+            *column = token->position - (source - parser->source) + 1;
+            break;
+        }
+
+        ++*line;
+        source = sep + 1;
+    }
+}
+
+static av_printf_format(4, 5)
+void vgs_log_invalid_token(
+    void *log_ctx,
+    const struct VGSParser *parser,
+    const struct VGSParserToken *token,
+    const char *extra_fmt,
+    ...
+) {
+    va_list ap;
+    char extra[256];
+    size_t line, column;
+
+    vgs_token_span(parser, token, &line, &column);
+
+    // Format extra message.
+    va_start(ap, extra_fmt);
+    vsnprintf(extra, sizeof(extra), extra_fmt, ap);
+    va_end(ap);
+
+    av_log(log_ctx, AV_LOG_ERROR,
+        "Invalid token '%.*s' at line %zu, column %zu: %s\n",
+        (int)token->length, token->lexeme, line, column, extra);
+}
+
+/// Return the next token in the source.
+///
+/// @param[out]  token    Next token.
+/// @param[in]   advance  If true, the cursor is updated after finding a token.
+///
+/// @return `0` on success, and a negative `AVERROR` code on failure.
+static int vgs_parser_next_token(
+    void *log_ctx,
+    struct VGSParser *parser,
+    struct VGSParserToken *token,
+    int advance
+) {
+
+    #define WORD_SEPARATOR " \n\t\r,"
+
+    int level;
+    size_t cursor, length;
+    const char *source;
+
+next_token:
+
+    source = &parser->source[parser->cursor];
+
+    cursor = strspn(source, WORD_SEPARATOR);
+    token->position = parser->cursor + cursor;
+    token->lexeme = &source[cursor];
+
+    switch (source[cursor]) {
+    case '\0':
+        token->type = TOKEN_EOF;
+        token->lexeme = "<EOF>";
+        token->length = 5;
+        return 0;
+
+    case '(':
+        // Find matching parenthesis.
+        level = 1;
+        length = 1;
+
+        while (level > 0) {
+            switch (source[cursor + length]) {
+            case '\0':
+                token->length = 1; // Show only the '(' in the error message.
+                vgs_log_invalid_token(log_ctx, parser, token, "Unmatched parenthesis.");
+                return AVERROR(EINVAL);
+
+            case '(':
+                level++;
+                break;
+
+            case ')':
+                level--;
+                break;
+            }
+
+            length++;
+        }
+
+        token->type = TOKEN_EXPR;
+        token->length = length;
+        break;
+
+    case '{':
+        token->type = TOKEN_LEFT_BRACKET;
+        token->length = 1;
+        break;
+
+    case '}':
+        token->type = TOKEN_RIGHT_BRACKET;
+        token->length = 1;
+        break;
+
+    case '+':
+    case '-':
+    case '.':
+    case '0':
+    case '1':
+    case '2':
+    case '3':
+    case '4':
+    case '5':
+    case '6':
+    case '7':
+    case '8':
+    case '9':
+        token->type = TOKEN_LITERAL;
+        token->length = strcspn(token->lexeme, WORD_SEPARATOR);
+        break;
+
+    case '/':
+        // If the next character is also '/', ignore the rest of
+        // the line.
+        //
+        // If it is something else, return a `TOKEN_WORD`.
+        if (source[cursor + 1] == '/') {
+            parser->cursor += cursor + strcspn(token->lexeme, "\n");
+            goto next_token;
+        }
+
+        /* fallthrough */
+
+    default:
+        token->type = TOKEN_WORD;
+        token->length = strcspn(token->lexeme, WORD_SEPARATOR);
+        break;
+    }
+
+    if (advance) {
+        parser->cursor += cursor + token->length;
+    }
+
+    return 0;
+}
+
+/// Command arguments.
+struct VGSArgument {
+    enum {
+        ARG_COLOR = 1,
+        ARG_COLOR_VAR,
+        ARG_CONST,
+        ARG_EXPR,
+        ARG_LITERAL,
+        ARG_METADATA,
+        ARG_PROCEDURE_ID,
+        ARG_SUBPROGRAM,
+        ARG_VARIABLE,
+    } type;
+
+    union {
+        uint8_t color[4];
+        int constant;
+        AVExpr *expr;
+        double literal;
+        int proc_id;
+        struct VGSProgram *subprogram;
+        int variable;
+    };
+
+    char *metadata;
+};
+
+/// Program statements.
+struct VGSStatement {
+    enum VGSCommand cmd;
+    struct VGSArgument *args;
+    int args_count;
+};
+
+struct VGSProgram {
+    struct VGSStatement *statements;
+    int statements_count;
+
+    const char **proc_names;
+    int proc_names_count;
+};
+
+static void vgs_free(struct VGSProgram *program);
+
+static int vgs_parse(
+    void *log_ctx,
+    struct VGSParser *parser,
+    struct VGSProgram *program,
+    int subprogram
+);
+
+static void vgs_statement_free(struct VGSStatement *stm) {
+    if (stm->args == NULL)
+        return;
+
+    for (int j = 0; j < stm->args_count; j++) {
+        struct VGSArgument *arg = &stm->args[j];
+
+        switch (arg->type) {
+        case ARG_EXPR:
+            av_expr_free(arg->expr);
+            break;
+
+        case ARG_SUBPROGRAM:
+            vgs_free(arg->subprogram);
+            av_freep(&arg->subprogram);
+            break;
+        }
+
+        if (arg->metadata)
+            av_freep(&arg->metadata);
+    }
+
+    av_freep(&stm->args);
+}
+
+/// Release the memory allocated by the program.
+static void vgs_free(struct VGSProgram *program) {
+    if (program->statements == NULL)
+        return;
+
+    for (int i = 0; i < program->statements_count; i++)
+        vgs_statement_free(&program->statements[i]);
+
+    av_freep(&program->statements);
+
+    if (program->proc_names != NULL) {
+        for (int i = 0; i < program->proc_names_count; i++)
+            av_freep(&program->proc_names[i]);
+
+        av_freep(&program->proc_names);
+    }
+}
+
+/// Consume the next argument as a numeric value, and store it in `arg`.
+///
+/// Return `0` on success, and a negative `AVERROR` code on failure.
+static int vgs_parse_numeric_argument(
+    void *log_ctx,
+    struct VGSParser *parser,
+    struct VGSArgument *arg,
+    int metadata
+) {
+    int ret;
+    char stack_buf[64];
+    char *lexeme, *endp;
+    struct VGSParserToken token;
+
+    ret = vgs_parser_next_token(log_ctx, parser, &token, 1);
+    if (ret != 0)
+        return ret;
+
+    // Convert the lexeme to a NUL-terminated string. Small lexemes are copied
+    // to a buffer on the stack; thus, it avoids allocating memory is most cases.
+    if (token.length + 1 < sizeof(stack_buf)) {
+        lexeme = stack_buf;
+    } else {
+        lexeme = av_malloc(token.length + 1);
+    }
+
+    memcpy(lexeme, token.lexeme, token.length);
+    lexeme[token.length] = '\0';
+
+    switch (token.type) {
+    case TOKEN_LITERAL:
+        arg->type = ARG_LITERAL;
+        arg->literal = av_strtod(lexeme, &endp);
+
+        if (*endp != '\0') {
+            vgs_log_invalid_token(log_ctx, parser, &token, "Expected valid number.");
+            ret = AVERROR(EINVAL);
+        }
+        break;
+
+    case TOKEN_EXPR:
+        arg->type = ARG_EXPR;
+        ret = av_expr_parse(
+            &arg->expr,
+            lexeme,
+            parser->var_names,
+            vgs_func1_names,
+            vgs_func1_impls,
+            vgs_func2_names,
+            vgs_func2_impls,
+            0,
+            log_ctx
+        );
+
+        if (ret != 0)
+            vgs_log_invalid_token(log_ctx, parser, &token, "Invalid expression.");
+
+        break;
+
+    case TOKEN_WORD:
+        ret = 1;
+        for (int i = 0; i < VAR_COUNT; i++) {
+            const char *var = parser->var_names[i];
+            if (var == NULL)
+                break;
+
+            if (vgs_token_is_string(&token, var)) {
+                arg->type = ARG_VARIABLE;
+                arg->variable = i;
+                ret = 0;
+                break;
+            }
+        }
+
+        if (ret == 0)
+            break;
+
+        /* fallthrough */
+
+    default:
+        vgs_log_invalid_token(log_ctx, parser, &token, "Expected numeric argument.");
+        ret = AVERROR(EINVAL);
+    }
+
+    if (ret == 0) {
+        if (metadata) {
+            size_t line, column;
+            vgs_token_span(parser, &token, &line, &column);
+            arg->metadata = av_asprintf("[%zu:%zu] %s", line, column, lexeme);
+        } else {
+            arg->metadata = NULL;
+        }
+    } else {
+        memset(arg, 0, sizeof(*arg));
+    }
+
+    if (lexeme != stack_buf)
+        av_freep(&lexeme);
+
+    return ret;
+}
+
+/// Check if the next token is a numeric value, so the last command must be
+/// repeated.
+static int vgs_parser_can_repeat_cmd(void *log_ctx, struct VGSParser *parser) {
+    struct VGSParserToken token = { 0 };
+
+    const int ret = vgs_parser_next_token(log_ctx, parser, &token, 0);
+
+    if (ret != 0)
+        return ret;
+
+    switch (token.type) {
+    case TOKEN_EXPR:
+    case TOKEN_LITERAL:
+        return 0;
+
+    case TOKEN_WORD:
+        // If the next token is a word, it will be considered to repeat
+        // the command only if it is a variable, and there is not
+        // known command with the same name.
+
+        if (vgs_get_command(token.lexeme, token.length) != NULL)
+            return 1;
+
+        for (int i = 0; i < VAR_COUNT; i++) {
+            const char *var = parser->var_names[i];
+            if (var == NULL)
+                return 1;
+
+            if (vgs_token_is_string(&token, var))
+                return 0;
+        }
+
+        return 1;
+
+    default:
+        return 1;
+    }
+}
+
+
+static int vgs_is_valid_identifier(const struct VGSParserToken *token) {
+    // An identifier is valid if:
+    //
+    //  - It starts with an alphabetic character or an underscore.
+    //  - Everything else, alphanumeric or underscore
+
+    for (int i = 0; i < token->length; i++) {
+        char c = token->lexeme[i];
+        if (c != '_'
+            && !(c >= 'a' && c <= 'z')
+            && !(c >= 'A' && c <= 'Z')
+            && !(i > 0 && c >= '0' && c <= '9')
+        ) {
+            return 0;
+        }
+    }
+
+    return 1;
+}
+
+/// Extract the arguments for a command, and add a new statement
+/// to the program.
+///
+/// On success, return `0`.
+static int vgs_parse_statement(
+    void *log_ctx,
+    struct VGSParser *parser,
+    struct VGSProgram *program,
+    const struct VGSCommandSpec *decl
+) {
+
+    #define FAIL(err) \
+        do {                                \
+            vgs_statement_free(&statement); \
+            return AVERROR(err);            \
+        } while(0)
+
+    struct VGSStatement statement = {
+        .cmd = decl->cmd,
+        .args = NULL,
+        .args_count = 0,
+    };
+
+    const struct VGSParameter *param = &decl->params[0];
+
+    int proc_args_count = 0;
+
+    for (;;) {
+        int ret;
+        void *r;
+
+        struct VGSParserToken token = { 0 };
+        struct VGSArgument arg = { 0 };
+
+        switch (param->type) {
+        case PARAM_VARIADIC:
+            // If the next token is numeric, repeat the previous parameter
+            // to append it to the current statement.
+
+            if (statement.args_count < MAX_COMMAND_PARAMS
+                && vgs_parser_can_repeat_cmd(log_ctx, parser) == 0
+            ) {
+                param--;
+            } else {
+                param++;
+            }
+
+            continue;
+
+        case PARAM_END:
+        case PARAM_MAY_REPEAT:
+            // Add the built statement to the program.
+            r = av_dynarray2_add(
+                (void*)&program->statements,
+                &program->statements_count,
+                sizeof(statement),
+                (void*)&statement
+            );
+
+            if (r == NULL)
+                FAIL(ENOMEM);
+
+            // May repeat if the next token is numeric.
+            if (param->type != PARAM_END
+                && vgs_parser_can_repeat_cmd(log_ctx, parser) == 0
+            ) {
+                param = &decl->params[0];
+                statement.args = NULL;
+                statement.args_count = 0;
+                continue;
+            }
+
+            return 0;
+
+        case PARAM_COLOR:
+            ret = vgs_parser_next_token(log_ctx, parser, &token, 1);
+            if (ret != 0)
+                FAIL(EINVAL);
+
+            arg.type = ARG_COLOR;
+
+            for (int i = VAR_U0; i < VAR_COUNT; i++) {
+                if (parser->var_names[i] == NULL)
+                    break;
+
+                if (vgs_token_is_string(&token, parser->var_names[i])) {
+                    arg.type = ARG_COLOR_VAR;
+                    arg.variable = i;
+                    break;
+                }
+            }
+
+            if (arg.type == ARG_COLOR_VAR)
+                break;
+
+            ret = av_parse_color(arg.color, token.lexeme, token.length, log_ctx);
+            if (ret != 0) {
+                vgs_log_invalid_token(log_ctx, parser, &token, "Expected color.");
+                FAIL(EINVAL);
+            }
+
+            break;
+
+        case PARAM_CONSTANT: {
+            int found = 0;
+            char expected_names[64] = { 0 };
+
+            ret = vgs_parser_next_token(log_ctx, parser, &token, 1);
+            if (ret != 0)
+                FAIL(EINVAL);
+
+            for (
+                const struct VGSConstant *constant = param->constants;
+                constant->name != NULL;
+                constant++
+            ) {
+                if (vgs_token_is_string(&token, constant->name)) {
+                    arg.type = ARG_CONST;
+                    arg.constant = constant->value;
+
+                    found = 1;
+                    break;
+                }
+
+                // Collect valid names to include them in the error message, in case
+                // the name is not found.
+                av_strlcatf(expected_names, sizeof(expected_names), " '%s'", constant->name);
+            }
+
+            if (!found) {
+                vgs_log_invalid_token(log_ctx, parser, &token, "Expected one of%s.", expected_names);
+                FAIL(EINVAL);
+            }
+
+            break;
+        }
+
+        case PARAM_PROC_ARGS:
+            if (vgs_parser_can_repeat_cmd(log_ctx, parser) != 0) {
+                // No more arguments. Jump to next parameter.
+                param++;
+                continue;
+            }
+
+            if (proc_args_count++ >= MAX_PROC_ARGS) {
+                vgs_log_invalid_token(log_ctx, parser, &token,
+                    "Too many arguments. Limit is %d", MAX_PROC_ARGS);
+                FAIL(EINVAL);
+            }
+
+            /* fallthrough */
+
+        case PARAM_NUMERIC:
+        case PARAM_NUMERIC_METADATA:
+            ret = vgs_parse_numeric_argument(
+                log_ctx,
+                parser,
+                &arg,
+                param->type == PARAM_NUMERIC_METADATA
+            );
+
+            if (ret != 0)
+                FAIL(EINVAL);
+
+            break;
+
+        case PARAM_PROC_NAME: {
+            int proc_id;
+
+            ret = vgs_parser_next_token(log_ctx, parser, &token, 1);
+            if (ret != 0)
+                FAIL(EINVAL);
+
+            if (!vgs_is_valid_identifier(&token)) {
+                vgs_log_invalid_token(log_ctx, parser, &token, "Invalid procedure name.");
+                FAIL(EINVAL);
+            }
+
+            // Use the index in the array as the identifier of the name.
+
+            for (proc_id = 0; proc_id < parser->proc_names_count; proc_id++) {
+                if (vgs_token_is_string(&token, parser->proc_names[proc_id]))
+                    break;
+            }
+
+            if (proc_id == parser->proc_names_count) {
+                const char *name = av_strndup(token.lexeme, token.length);
+
+                const char **r = av_dynarray2_add(
+                    (void*)&parser->proc_names,
+                    &parser->proc_names_count,
+                    sizeof(name),
+                    (void*)&name
+                );
+
+                if (r == NULL) {
+                    av_freep(&name);
+                    FAIL(ENOMEM);
+                }
+            }
+
+            arg.type = ARG_PROCEDURE_ID;
+            arg.proc_id = proc_id;
+
+            break;
+        }
+
+        case PARAM_RAW_IDENT:
+            ret = vgs_parser_next_token(log_ctx, parser, &token, 1);
+            if (ret != 0)
+                FAIL(EINVAL);
+
+            switch (token.type) {
+            case TOKEN_LITERAL:
+            case TOKEN_WORD:
+                arg.type = ARG_METADATA;
+                arg.metadata = av_strndup(token.lexeme, token.length);
+                break;
+
+            default:
+                vgs_log_invalid_token(log_ctx, parser, &token, "Expected '{'.");
+                FAIL(EINVAL);
+            }
+
+            break;
+
+        case PARAM_SUBPROGRAM:
+            ret = vgs_parser_next_token(log_ctx, parser, &token, 1);
+            if (ret != 0)
+                FAIL(EINVAL);
+
+            if (token.type != TOKEN_LEFT_BRACKET) {
+                vgs_log_invalid_token(log_ctx, parser, &token, "Expected '{'.");
+                FAIL(EINVAL);
+            }
+
+            arg.type = ARG_SUBPROGRAM;
+            arg.subprogram = av_mallocz(sizeof(struct VGSProgram));
+
+            ret = vgs_parse(log_ctx, parser, arg.subprogram, 1);
+            if (ret != 0) {
+                av_freep(&arg.subprogram);
+                FAIL(EINVAL);
+            }
+
+            break;
+
+        case PARAM_PROC_PARAMS:
+            ret = vgs_parser_next_token(log_ctx, parser, &token, 0);
+            if (ret != 0)
+                FAIL(EINVAL);
+
+            if (token.type == TOKEN_WORD && proc_args_count++ >= MAX_PROC_ARGS) {
+                vgs_log_invalid_token(log_ctx, parser, &token,
+                    "Too many parameters. Limit is %d", MAX_PROC_ARGS);
+                FAIL(EINVAL);
+            }
+
+            if (token.type != TOKEN_WORD) {
+                // No more variables. Jump to next parameter.
+                param++;
+                continue;
+            }
+
+            /* fallthrough */
+
+        case PARAM_VAR_NAME: {
+            int var_idx = -1;
+
+            ret = vgs_parser_next_token(log_ctx, parser, &token, 1);
+            if (ret != 0)
+                FAIL(EINVAL);
+
+            // Find the slot where the variable is allocated, or the next
+            // available slot if it is a new variable.
+            for (int i = 0; i < VAR_COUNT; i++) {
+                if (parser->var_names[i] == NULL
+                    || vgs_token_is_string(&token, parser->var_names[i])
+                ) {
+                    var_idx = i;
+                    break;
+                }
+            }
+
+            // No free slots to allocate new variables.
+            if (var_idx == -1) {
+                vgs_log_invalid_token(log_ctx, parser, &token,
+                    "Too many user variables. Can define up to %d variables.", USER_VAR_COUNT);
+                FAIL(E2BIG);
+            }
+
+            // If the index is before `VAR_U0`, the name is already taken by
+            // a default variable.
+            if (var_idx < VAR_U0) {
+                vgs_log_invalid_token(log_ctx, parser, &token, "Reserved variable name.");
+                FAIL(EINVAL);
+            }
+
+            // Need to allocate a new variable.
+            if (parser->var_names[var_idx] == NULL) {
+                if (!vgs_is_valid_identifier(&token)) {
+                    vgs_log_invalid_token(log_ctx, parser, &token, "Invalid variable name.");
+                    FAIL(EINVAL);
+                }
+
+                parser->var_names[var_idx] = av_strndup(token.lexeme, token.length);
+            }
+
+            arg.type = ARG_CONST;
+            arg.constant = var_idx;
+            break;
+        }
+
+        default:
+            av_assert0(0); /* unreachable */
+        }
+
+        r = av_dynarray2_add(
+            (void*)&statement.args,
+            &statement.args_count,
+            sizeof(arg),
+            (void*)&arg
+        );
+
+        if (r == NULL)
+            FAIL(ENOMEM);
+
+        switch (param->type) {
+            case PARAM_PROC_ARGS:
+            case PARAM_PROC_PARAMS:
+                // Don't update params.
+                break;
+
+            default:
+                param++;
+        }
+    }
+
+    #undef FAIL
+}
+
+static void vgs_parser_init(struct VGSParser *parser, const char *source) {
+    parser->source = source;
+    parser->cursor = 0;
+
+    parser->proc_names = NULL;
+    parser->proc_names_count = 0;
+
+    memset(parser->var_names, 0, sizeof(parser->var_names));
+    for (int i = 0; i < VAR_U0; i++)
+        parser->var_names[i] = vgs_default_vars[i];
+}
+
+static void vgs_parser_free(struct VGSParser *parser) {
+    for (int i = VAR_U0; i < VAR_COUNT; i++)
+        if (parser->var_names[i] != NULL)
+            av_freep(&parser->var_names[i]);
+
+    if (parser->proc_names != NULL) {
+        for (int i = 0; i < parser->proc_names_count; i++)
+            av_freep(&parser->proc_names[i]);
+
+        av_freep(&parser->proc_names);
+    }
+}
+
+/// Build a program by parsing a script.
+///
+/// `subprogram` must be true when the function is called to parse the body of
+/// a block (like `if` or `proc` commands).
+///
+/// Return `0` on success, and a negative `AVERROR` code on failure.
+static int vgs_parse(
+    void *log_ctx,
+    struct VGSParser *parser,
+    struct VGSProgram *program,
+    int subprogram
+) {
+    struct VGSParserToken token;
+
+    program->statements = NULL;
+    program->statements_count = 0;
+
+    program->proc_names = NULL;
+    program->proc_names_count = 0;
+
+    for (;;) {
+        int ret;
+        const struct VGSCommandSpec *cmd;
+
+        ret = vgs_parser_next_token(log_ctx, parser, &token, 1);
+        if (ret != 0)
+            goto fail;
+
+        switch (token.type) {
+        case TOKEN_EOF:
+            if (subprogram) {
+                vgs_log_invalid_token(log_ctx, parser, &token, "Expected '}'.");
+                goto fail;
+            } else {
+                // Move the proc names to the main program.
+                FFSWAP(const char **, program->proc_names, parser->proc_names);
+                FFSWAP(int, program->proc_names_count, parser->proc_names_count);
+            }
+
+            return 0;
+
+        case TOKEN_WORD:
+            // The token must be a valid command.
+            cmd = vgs_get_command(token.lexeme, token.length);
+            if (cmd == NULL)
+                goto invalid_token;
+
+            ret = vgs_parse_statement(log_ctx, parser, program, cmd);
+            if (ret != 0)
+                goto fail;
+
+            break;
+
+        case TOKEN_RIGHT_BRACKET:
+            if (!subprogram)
+                goto invalid_token;
+
+            return 0;
+
+        default:
+            goto invalid_token;
+        }
+    }
+
+    return AVERROR_BUG; /* unreachable */
+
+invalid_token:
+    vgs_log_invalid_token(log_ctx, parser, &token, "Expected command.");
+
+fail:
+    vgs_free(program);
+    return AVERROR(EINVAL);
+}
+
+/*
+ * == Interpreter ==
+ *
+ * The interpreter takes the `VGSProgram` built by the parser, and translate the
+ * statements to calls to cairo.
+ *
+ * `VGSEvalState` tracks the state needed to execute such commands.
+ */
+
+/// Number of different states for the `randomg` function.
+#define RANDOM_STATES 4
+
+/// Block assigned to a procedure by a call to the `proc` command.
+struct VGSProcedure {
+    const struct VGSProgram *program;
+
+    /// Number of expected arguments.
+    int proc_args_count;
+
+    /// Variable ids where each argument is stored.
+    int args[MAX_PROC_ARGS];
+};
+
+struct VGSEvalState {
+    void *log_ctx;
+
+    /// Current frame.
+    AVFrame *frame;
+
+    /// Cairo context for drawing operations.
+    cairo_t *cairo_ctx;
+
+    /// Pattern being built by commands like `colorstop`.
+    cairo_pattern_t *pattern_builder;
+
+    /// Register if `break` was called in a subprogram.
+    int interrupted;
+
+    /// Next call to `[eo]fill`, `[eo]clip`, or `stroke`, should use
+    /// the `_preserve` function.
+    int preserve_path;
+
+    /// Subprograms associated to each procedure identifier.
+    struct VGSProcedure *procedures;
+
+    /// Reference to the procedure names in the `VGSProgram`.
+    const char *const *proc_names;
+
+    /// Values for the variables in expressions.
+    ///
+    /// Some variables (like `cx` or `cy`) are written before
+    /// executing each statement.
+    double vars[VAR_COUNT];
+
+    /// State for each index available for the `randomg` function.
+    FFSFC64 random_state[RANDOM_STATES];
+
+    /// Frame metadata, if any.
+    AVDictionary *metadata;
+
+    // Reflected Control Points. Used in T and S commands.
+    //
+    // See https://www.w3.org/TR/SVG/paths.html#ReflectedControlPoints
+    struct {
+        enum { RCP_NONE, RCP_VALID, RCP_UPDATED } status;
+
+        double cubic_x;
+        double cubic_y;
+        double quad_x;
+        double quad_y;
+    } rcp;
+};
+
+/// Function `pathlen(n)` for `av_expr_eval`.
+///
+/// Compute the length of the current path in the cairo context. If `n > 0`, it
+/// is the maximum number of segments to be added to the length.
+static double vgs_fn_pathlen(void *data, double arg) {
+    if (!isfinite(arg))
+        return NAN;
+
+    const struct VGSEvalState *state = (struct VGSEvalState *)data;
+
+    int max_segments = (int)arg;
+
+    double lmx = NAN, lmy = NAN; // last move point
+    double cx = NAN, cy = NAN;   // current point.
+
+    double length = 0;
+    cairo_path_t *path = cairo_copy_path_flat(state->cairo_ctx);
+
+    for (int i = 0; i < path->num_data; i += path->data[i].header.length) {
+        double x, y;
+        cairo_path_data_t *data = &path->data[i];
+
+        switch (data[0].header.type) {
+        case CAIRO_PATH_MOVE_TO:
+            cx = lmx = data[1].point.x;
+            cy = lmy = data[1].point.y;
+
+            // Don't update `length`.
+            continue;
+
+        case CAIRO_PATH_LINE_TO:
+            x = data[1].point.x;
+            y = data[1].point.y;
+            break;
+
+        case CAIRO_PATH_CLOSE_PATH:
+            x = lmx;
+            y = lmy;
+            break;
+
+        default:
+            continue;
+        }
+
+        length += hypot(cx - x, cy - y);
+
+        cx = x;
+        cy = y;
+
+        // If the function argument is `> 0`, use it as a limit for how
+        // many segments are added up.
+        if (--max_segments == 0)
+            break;
+    }
+
+    cairo_path_destroy(path);
+
+    return length;
+}
+
+/// Function `randomg(n)` for `av_expr_eval`.
+///
+/// Compute a random value between 0 and 1. Similar to `random()`, but the
+/// state is global to the VGS program.
+///
+/// The last 2 bits of the integer representation of the argument are used
+/// as the state index. If the state is not initialized, the argument is
+/// the seed for that state.
+static double vgs_fn_randomg(void *data, double arg) {
+    if (!isfinite(arg))
+        return arg;
+
+    struct VGSEvalState *state = (struct VGSEvalState *)data;
+
+    const uint64_t iarg = (uint64_t)arg;
+    const int rng_idx = iarg % FF_ARRAY_ELEMS(state->random_state);
+
+    FFSFC64 *rng = &state->random_state[rng_idx];
+
+    if (rng->counter == 0)
+        ff_sfc64_init(rng, iarg, iarg, iarg, 12);
+
+    return ff_sfc64_get(rng) * (1.0 / UINT64_MAX);
+}
+
+/// Function `p(x, y)` for `av_expr_eval`.
+///
+/// Return the pixel color in 0xRRGGBBAA format.
+///
+/// The transformation matrix is applied to the given coordinates.
+///
+/// If the coordinates are outside the frame, return NAN.
+static double vgs_fn_p(void* data, double x0, double y0) {
+    const struct VGSEvalState *state = (struct VGSEvalState *)data;
+    const AVFrame *frame = state->frame;
+
+    if (frame == NULL || !isfinite(x0) || !isfinite(y0))
+        return NAN;
+
+    cairo_user_to_device(state->cairo_ctx, &x0, &y0);
+
+    const int x = (int)x0;
+    const int y = (int)y0;
+
+    if (x < 0 || y < 0 || x >= frame->width || y >= frame->height)
+        return NAN;
+
+    const AVPixFmtDescriptor *desc = av_pix_fmt_desc_get(frame->format);
+
+    uint32_t color[4] = { 0, 0, 0, 255 };
+
+    for (int c = 0; c < desc->nb_components; c++) {
+        uint32_t pixel;
+        const int depth = desc->comp[c].depth;
+
+        av_read_image_line2(
+            &pixel,
+            (void*)frame->data,
+            frame->linesize,
+            desc,
+            x, y,
+            c,
+            1, // width
+            0, // read_pal_component
+            4  // dst_element_size
+        );
+
+        if (depth != 8) {
+            pixel = pixel * 255 / ((1 << depth) - 1);
+        }
+
+        color[c] = pixel;
+    }
+
+    return color[0] << 24 | color[1] << 16 | color[2] << 8 | color[3];
+}
+
+static void vgs_eval_state_init(
+    struct VGSEvalState *state,
+    const struct VGSProgram *program,
+    void *log_ctx,
+    AVFrame *frame
+) {
+    memset(state, 0, sizeof(*state));
+
+    state->log_ctx = log_ctx;
+    state->frame = frame;
+    state->rcp.status = RCP_NONE;
+
+    if (program->proc_names != NULL) {
+        state->procedures = av_calloc(sizeof(struct VGSProcedure), program->proc_names_count);
+        state->proc_names = program->proc_names;
+    }
+
+    for (int i = 0; i < VAR_COUNT; i++)
+        state->vars[i] = NAN;
+}
+
+static void vgs_eval_state_free(struct VGSEvalState *state) {
+    if (state->pattern_builder != NULL)
+        cairo_pattern_destroy(state->pattern_builder);
+
+    if (state->procedures != NULL)
+        av_free(state->procedures);
+
+    memset(state, 0, sizeof(*state));
+}
+
+/// Draw an ellipse. `x`/`y` specifies the center, and `rx`/`ry` the radius of
+/// the ellipse on the x/y axis.
+///
+/// Cairo does not provide a native way to create an ellipse, but it can be done
+/// by scaling the Y axis with the transformation matrix.
+static void draw_ellipse(cairo_t *c, double x, double y, double rx, double ry) {
+    cairo_save(c);
+    cairo_translate(c, x, y);
+
+    if (rx != ry)
+        cairo_scale(c, 1, ry / rx);
+
+    cairo_new_sub_path(c);
+    cairo_arc(c, 0, 0, rx, 0, 2 * M_PI);
+    cairo_close_path(c);
+    cairo_new_sub_path(c);
+
+    cairo_restore(c);
+}
+
+/// Draw a quadratic bezier from the current point to `x, y`, The control point
+/// is specified by `x1, y1`.
+///
+/// If the control point is NAN, use the reflected point.
+///
+/// cairo only supports cubic cuvers, so control points must be adjusted to
+/// simulate the behaviour in SVG.
+static void draw_quad_curve_to(
+    struct VGSEvalState *state,
+    int relative,
+    double x1,
+    double y1,
+    double x,
+    double y
+) {
+    double x0 = 0, y0 = 0;  // Current point.
+    double xa, ya, xb, yb;  // Control points for the cubic curve.
+
+    const int use_reflected = isnan(x1);
+
+    cairo_get_current_point(state->cairo_ctx, &x0, &y0);
+
+    if (relative) {
+        if (!use_reflected) {
+            x1 += x0;
+            y1 += y0;
+        }
+
+        x += x0;
+        y += y0;
+    }
+
+    if (use_reflected) {
+        if (state->rcp.status != RCP_NONE) {
+            x1 = state->rcp.quad_x;
+            y1 = state->rcp.quad_y;
+        } else {
+            x1 = x0;
+            y1 = y0;
+        }
+    }
+
+    xa = (x0 + 2 * x1) / 3;
+    ya = (y0 + 2 * y1) / 3;
+    xb = (x + 2 * x1) / 3;
+    yb = (y + 2 * y1) / 3;
+    cairo_curve_to(state->cairo_ctx, xa, ya, xb, yb, x, y);
+
+    state->rcp.status = RCP_UPDATED;
+    state->rcp.cubic_x = x1;
+    state->rcp.cubic_y = y1;
+    state->rcp.quad_x = 2 * x - x1;
+    state->rcp.quad_y = 2 * y - y1;
+}
+
+/// Similar to quad_curve_to, but for cubic curves.
+static void draw_cubic_curve_to(
+    struct VGSEvalState *state,
+    int relative,
+    double x1,
+    double y1,
+    double x2,
+    double y2,
+    double x,
+    double y
+) {
+    double x0 = 0, y0 = 0; // Current point.
+
+    const int use_reflected = isnan(x1);
+
+    cairo_get_current_point(state->cairo_ctx, &x0, &y0);
+
+    if (relative) {
+        if (!use_reflected) {
+            x1 += x0;
+            y1 += y0;
+        }
+
+        x += x0;
+        y += y0;
+        x2 += x0;
+        y2 += y0;
+    }
+
+    if (use_reflected) {
+        if (state->rcp.status != RCP_NONE) {
+            x1 = state->rcp.cubic_x;
+            y1 = state->rcp.cubic_y;
+        } else {
+            x1 = x0;
+            y1 = y0;
+        }
+    }
+
+    cairo_curve_to(state->cairo_ctx, x1, y1, x2, y2, x, y);
+
+    state->rcp.status = RCP_UPDATED;
+    state->rcp.cubic_x = 2 * x - x2;
+    state->rcp.cubic_y = 2 * y - y2;
+    state->rcp.quad_x = x2;
+    state->rcp.quad_y = y2;
+}
+
+static void draw_rounded_rect(
+    cairo_t *c,
+    double x,
+    double y,
+    double width,
+    double height,
+    double radius
+) {
+    radius = av_clipd(radius, 0, FFMIN(height / 2, width / 2));
+
+    cairo_new_sub_path(c);
+    cairo_arc(c, x + radius, y + radius, radius, M_PI, 3 * M_PI / 2);
+    cairo_arc(c, x + width - radius, y + radius, radius, 3 * M_PI / 2, 2 * M_PI);
+    cairo_arc(c, x + width - radius, y + height - radius, radius, 0, M_PI / 2);
+    cairo_arc(c, x + radius, y + height - radius, radius, M_PI / 2, M_PI);
+    cairo_close_path(c);
+}
+
+static void hsl2rgb(
+    double h,
+    double s,
+    double l,
+    double *pr,
+    double *pg,
+    double *pb
+) {
+    // https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB
+
+    double r, g, b, chroma, x, h1;
+
+    if (h < 0 || h >= 360)
+        h = fmod(FFMAX(h, 0), 360);
+
+    s = av_clipd(s, 0, 1);
+    l = av_clipd(l, 0, 1);
+
+    chroma = (1 - fabs(2 * l - 1)) * s;
+    h1 = h / 60;
+    x = chroma * (1 - fabs(fmod(h1, 2) - 1));
+
+    switch ((int)floor(h1)) {
+    case 0:
+        r = chroma;
+        g = x;
+        b = 0;
+        break;
+
+    case 1:
+        r = x;
+        g = chroma;
+        b = 0;
+        break;
+
+    case 2:
+        r = 0;
+        g = chroma;
+        b = x;
+        break;
+
+    case 3:
+        r = 0;
+        g = x;
+        b = chroma;
+        break;
+
+    case 4:
+        r = x;
+        g = 0;
+        b = chroma;
+        break;
+
+    default:
+        r = chroma;
+        g = 0;
+        b = x;
+        break;
+
+    }
+
+    x = l - chroma / 2;
+
+    *pr = r + x;
+    *pg = g + x;
+    *pb = b + x;
+}
+
+/// Interpreter for `VGSProgram`.
+///
+/// Its implementation is a simple switch-based dispatch.
+///
+/// To evaluate blocks (like `if` or `call`), it makes a recursive call with
+/// the subprogram allocated to the block.
+static int vgs_eval(
+    struct VGSEvalState *state,
+    const struct VGSProgram *program
+) {
+
+    #define ASSERT_ARGS(n) av_assert0(statement->args_count == n)
+
+    // When `preserve` is used, the next call to `clip`, `fill`, or `stroke`
+    // uses the `cairo_..._preserve` function.
+    #define MAY_PRESERVE(funcname) \
+        do {                                           \
+            if (state->preserve_path) {                \
+                state->preserve_path = 0;              \
+                funcname##_preserve(state->cairo_ctx); \
+            } else {                                   \
+                funcname(state->cairo_ctx);            \
+            }                                          \
+        } while(0)
+
+    double numerics[MAX_COMMAND_PARAMS];
+    double colors[MAX_COMMAND_PARAMS][4];
+
+    double cx, cy; // Current point.
+
+    int relative;
+
+    for (int st_number = 0; st_number < program->statements_count; st_number++) {
+        const struct VGSStatement *statement = &program->statements[st_number];
+
+        if (statement->args_count > FF_ARRAY_ELEMS(numerics)) {
+            av_log(state->log_ctx, AV_LOG_ERROR, "Too many arguments (%d).\n", statement->args_count);
+            return AVERROR_BUG;
+        }
+
+        if (cairo_has_current_point(state->cairo_ctx)) {
+            cairo_get_current_point(state->cairo_ctx, &cx, &cy);
+        } else {
+            cx = NAN;
+            cy = NAN;
+        }
+
+        state->vars[VAR_CX] = cx;
+        state->vars[VAR_CY] = cy;
+
+        // Compute arguments.
+        for (int arg = 0; arg < statement->args_count; arg++) {
+            uint8_t color[4];
+
+            const struct VGSArgument *a = &statement->args[arg];
+
+            switch (a->type) {
+            case ARG_COLOR:
+            case ARG_COLOR_VAR:
+                if (a->type == ARG_COLOR) {
+                    memcpy(color, a->color, sizeof(color));
+                } else {
+                    uint32_t c = av_be2ne32((uint32_t)state->vars[a->variable]);
+                    memcpy(color, &c, sizeof(color));
+                }
+
+                colors[arg][0] = (double)(color[0]) / 255.0,
+                colors[arg][1] = (double)(color[1]) / 255.0,
+                colors[arg][2] = (double)(color[2]) / 255.0,
+                colors[arg][3] = (double)(color[3]) / 255.0;
+                break;
+
+            case ARG_EXPR:
+                numerics[arg] = av_expr_eval(a->expr, state->vars, state);
+                break;
+
+            case ARG_LITERAL:
+                numerics[arg] = a->literal;
+                break;
+
+            case ARG_VARIABLE:
+                av_assert0(a->variable < VAR_COUNT);
+                numerics[arg] = state->vars[a->variable];
+                break;
+
+            default:
+                numerics[arg] = NAN;
+                break;
+            }
+        }
+
+        // If the command uses a pending pattern (like a solid color
+        // or a gradient), set it to the cairo context before executing
+        // stroke/fill commands.
+        if (state->pattern_builder != NULL) {
+            switch (statement->cmd) {
+            case CMD_FILL:
+            case CMD_FILL_EO:
+            case CMD_RESTORE:
+            case CMD_SAVE:
+            case CMD_STROKE:
+                cairo_set_source(state->cairo_ctx, state->pattern_builder);
+                cairo_pattern_destroy(state->pattern_builder);
+                state->pattern_builder = NULL;
+            }
+        }
+
+        // Execute the command.
+        switch (statement->cmd) {
+        case CMD_ARC:
+            ASSERT_ARGS(5);
+            cairo_arc(
+                state->cairo_ctx,
+                numerics[0],
+                numerics[1],
+                numerics[2],
+                numerics[3],
+                numerics[4]
+            );
+            break;
+
+        case CMD_ARC_NEG:
+            ASSERT_ARGS(5);
+            cairo_arc_negative(
+                state->cairo_ctx,
+                numerics[0],
+                numerics[1],
+                numerics[2],
+                numerics[3],
+                numerics[4]
+            );
+            break;
+
+        case CMD_CIRCLE:
+            ASSERT_ARGS(3);
+            draw_ellipse(state->cairo_ctx, numerics[0], numerics[1], numerics[2], numerics[2]);
+            break;
+
+        case CMD_CLIP:
+        case CMD_CLIP_EO:
+            ASSERT_ARGS(0);
+            cairo_set_fill_rule(
+                state->cairo_ctx,
+                statement->cmd == CMD_CLIP ?
+                    CAIRO_FILL_RULE_WINDING :
+                    CAIRO_FILL_RULE_EVEN_ODD
+            );
+
+            MAY_PRESERVE(cairo_clip);
+            break;
+
+        case CMD_CLOSE_PATH:
+            ASSERT_ARGS(0);
+            cairo_close_path(state->cairo_ctx);
+            break;
+
+        case CMD_COLOR_STOP:
+            if (state->pattern_builder == NULL) {
+                av_log(state->log_ctx, AV_LOG_ERROR, "colorstop with no active gradient.\n");
+                break;
+            }
+
+            ASSERT_ARGS(2);
+            cairo_pattern_add_color_stop_rgba(
+                state->pattern_builder,
+                numerics[0],
+                colors[1][0],
+                colors[1][1],
+                colors[1][2],
+                colors[1][3]
+            );
+            break;
+
+        case CMD_CURVE_TO:
+        case CMD_CURVE_TO_REL:
+            ASSERT_ARGS(6);
+            draw_cubic_curve_to(
+                state,
+                statement->cmd == CMD_CURVE_TO_REL,
+                numerics[0],
+                numerics[1],
+                numerics[2],
+                numerics[3],
+                numerics[4],
+                numerics[5]
+            );
+            break;
+
+        case CMD_DEF_HSLA:
+        case CMD_DEF_RGBA: {
+            double r, g, b;
+
+            ASSERT_ARGS(5);
+
+            const int user_var = statement->args[0].variable;
+            av_assert0(user_var >= VAR_U0 && user_var < (VAR_U0 + USER_VAR_COUNT));
+
+            if (statement->cmd == CMD_DEF_HSLA) {
+                hsl2rgb(numerics[1], numerics[2], numerics[3], &r, &g, &b);
+            } else {
+                r = numerics[1];
+                g = numerics[2];
+                b = numerics[3];
+            }
+
+            #define C(v, o) ((uint32_t)(av_clipd(v, 0, 1) * 255) << o)
+
+            state->vars[user_var] = (double)(
+                C(r, 24)
+                | C(g, 16)
+                | C(b, 8)
+                | C(numerics[4], 0)
+            );
+
+            #undef C
+
+            break;
+        }
+
+        case CMD_ELLIPSE:
+            ASSERT_ARGS(4);
+            draw_ellipse(state->cairo_ctx, numerics[0], numerics[1], numerics[2], numerics[3]);
+            break;
+
+        case CMD_FILL:
+        case CMD_FILL_EO:
+            ASSERT_ARGS(0);
+
+            cairo_set_fill_rule(
+                state->cairo_ctx,
+                statement->cmd == CMD_FILL ?
+                    CAIRO_FILL_RULE_WINDING :
+                    CAIRO_FILL_RULE_EVEN_ODD
+            );
+
+            MAY_PRESERVE(cairo_fill);
+            break;
+
+        case CMD_GET_METADATA: {
+            ASSERT_ARGS(2);
+
+            double value = NAN;
+
+            const int user_var = statement->args[0].constant;
+            const char *key = statement->args[1].metadata;
+
+            av_assert0(user_var >= VAR_U0 && user_var < (VAR_U0 + USER_VAR_COUNT));
+
+            if (state->metadata != NULL && key != NULL) {
+                char *endp;
+                AVDictionaryEntry *entry = av_dict_get(state->metadata, key, NULL, 0);
+
+                if (entry != NULL) {
+                    value = av_strtod(entry->value, &endp);
+
+                    if (*endp != '\0')
+                        value = NAN;
+                }
+            }
+
+            state->vars[user_var] = value;
+            break;
+        }
+
+        case CMD_BREAK:
+            state->interrupted = 1;
+            return 0;
+
+        case CMD_IF:
+            ASSERT_ARGS(2);
+
+            if (isfinite(numerics[0]) && numerics[0] != 0.0) {
+                int ret = vgs_eval(state, statement->args[1].subprogram);
+                if (ret != 0 || state->interrupted != 0)
+                    return ret;
+            }
+
+            break;
+
+        case CMD_LINEAR_GRAD:
+            ASSERT_ARGS(4);
+
+            if (state->pattern_builder != NULL)
+                cairo_pattern_destroy(state->pattern_builder);
+
+            state->pattern_builder = cairo_pattern_create_linear(
+                numerics[0],
+                numerics[1],
+                numerics[2],
+                numerics[3]
+            );
+            break;
+
+        case CMD_LINE_TO:
+            ASSERT_ARGS(2);
+            cairo_line_to(state->cairo_ctx, numerics[0], numerics[1]);
+            break;
+
+        case CMD_LINE_TO_REL:
+            ASSERT_ARGS(2);
+            cairo_rel_line_to(state->cairo_ctx, numerics[0], numerics[1]);
+            break;
+
+        case CMD_MOVE_TO:
+            ASSERT_ARGS(2);
+            cairo_move_to(state->cairo_ctx, numerics[0], numerics[1]);
+            break;
+
+        case CMD_MOVE_TO_REL:
+            ASSERT_ARGS(2);
+            cairo_rel_move_to(state->cairo_ctx, numerics[0], numerics[1]);
+            break;
+
+        case CMD_NEW_PATH:
+            ASSERT_ARGS(0);
+            cairo_new_sub_path(state->cairo_ctx);
+            break;
+
+        case CMD_PRESERVE:
+            ASSERT_ARGS(0);
+            state->preserve_path = 1;
+            break;
+
+        case CMD_PRINT: {
+            char msg[256];
+            int len = 0;
+
+            for (int i = 0; i < statement->args_count; i++) {
+                int written;
+                int capacity = sizeof(msg) - len;
+
+                written = snprintf(
+                    msg + len,
+                    capacity,
+                    "%s%s = %f",
+                    i > 0 ? " | " : "",
+                    statement->args[i].metadata,
+                    numerics[i]
+                );
+
+                // If buffer is too small, discard the latest arguments.
+                if (written >= capacity)
+                    break;
+
+                len += written;
+            }
+
+            av_log(state->log_ctx, AV_LOG_INFO, "%.*s\n", len, msg);
+            break;
+        }
+
+        case CMD_PROC_ASSIGN: {
+            struct VGSProcedure *proc;
+
+            const int proc_args = statement->args_count - 2;
+            av_assert0(proc_args >= 0 && proc_args <= MAX_PROC_ARGS);
+
+            proc = &state->procedures[statement->args[0].proc_id];
+            proc->program = statement->args[proc_args + 1].subprogram;
+            proc->proc_args_count = proc_args;
+
+            for (int i = 0; i < MAX_PROC_ARGS; i++)
+                proc->args[i] = i < proc_args ? statement->args[i + 1].constant : -1;
+
+            break;
+        }
+
+        case CMD_PROC_CALL: {
+            const int proc_args = statement->args_count - 1;
+            av_assert0(proc_args >= 0 && proc_args <= MAX_PROC_ARGS);
+
+            const int proc_id = statement->args[0].proc_id;
+
+            const struct VGSProcedure *proc = &state->procedures[proc_id];
+
+            if (proc->proc_args_count != proc_args) {
+                av_log(
+                    state->log_ctx,
+                    AV_LOG_ERROR,
+                    "Procedure expects %d arguments, but received %d.",
+                    proc->proc_args_count,
+                    proc_args
+                );
+
+                break;
+            }
+
+            if (proc->program == NULL) {
+                const char *proc_name = state->proc_names[proc_id];
+                av_log(state->log_ctx, AV_LOG_ERROR,
+                    "Missing body for procedure '%s'\n", proc_name);
+            } else {
+                int ret;
+                double current_vars[MAX_PROC_ARGS] = { 0 };
+
+                // Set variables for the procedure arguments
+                for (int i = 0; i < proc_args; i++) {
+                    const int var = proc->args[i];
+                    if (var != -1) {
+                        current_vars[i] = state->vars[var];
+                        state->vars[var] = numerics[i + 1];
+                    }
+                }
+
+                ret = vgs_eval(state, proc->program);
+
+                // Restore variable values.
+                for (int i = 0; i < proc_args; i++) {
+                    const int var = proc->args[i];
+                    if (var != -1) {
+                        state->vars[var] = current_vars[i];
+                    }
+                }
+
+                if (ret != 0)
+                    return ret;
+
+                // `break` interrupts the procedure, but don't stop the program.
+                if (state->interrupted) {
+                    state->interrupted = 0;
+                    break;
+                }
+            }
+
+            break;
+        }
+
+        case CMD_Q_CURVE_TO:
+        case CMD_Q_CURVE_TO_REL:
+            ASSERT_ARGS(4);
+            relative = statement->cmd == CMD_Q_CURVE_TO_REL;
+            draw_quad_curve_to(
+                state,
+                relative,
+                numerics[0],
+                numerics[1],
+                numerics[2],
+                numerics[3]
+            );
+            break;
+
+        case CMD_RADIAL_GRAD:
+            ASSERT_ARGS(6);
+
+            if (state->pattern_builder != NULL)
+                cairo_pattern_destroy(state->pattern_builder);
+
+            state->pattern_builder = cairo_pattern_create_radial(
+                numerics[0],
+                numerics[1],
+                numerics[2],
+                numerics[3],
+                numerics[4],
+                numerics[5]
+            );
+            break;
+
+        case CMD_RESET_CLIP:
+            cairo_reset_clip(state->cairo_ctx);
+            break;
+
+        case CMD_RESET_DASH:
+            cairo_set_dash(state->cairo_ctx, NULL, 0, 0);
+            break;
+
+        case CMD_RESET_MATRIX:
+            cairo_identity_matrix(state->cairo_ctx);
+            break;
+
+        case CMD_RECT:
+            ASSERT_ARGS(4);
+            cairo_rectangle(state->cairo_ctx, numerics[0], numerics[1], numerics[2], numerics[3]);
+            break;
+
+        case CMD_REPEAT: {
+            double var_i = state->vars[VAR_I];
+
+            ASSERT_ARGS(2);
+
+            if (!isfinite(numerics[0]))
+                break;
+
+            for (int i = 0, count = (int)numerics[0]; i < count; i++) {
+                state->vars[VAR_I] = i;
+
+                const int ret = vgs_eval(state, statement->args[1].subprogram);
+                if (ret != 0)
+                    return ret;
+
+                // `break` interrupts the loop, but don't stop the program.
+                if (state->interrupted) {
+                    state->interrupted = 0;
+                    break;
+                }
+            }
+
+            state->vars[VAR_I] = var_i;
+            break;
+        }
+
+        case CMD_RESTORE:
+            ASSERT_ARGS(0);
+            cairo_restore(state->cairo_ctx);
+            break;
+
+        case CMD_ROTATE:
+            ASSERT_ARGS(1);
+            cairo_rotate(state->cairo_ctx, numerics[0]);
+            break;
+
+        case CMD_ROUNDEDRECT:
+            ASSERT_ARGS(5);
+            draw_rounded_rect(
+                state->cairo_ctx,
+                numerics[0],
+                numerics[1],
+                numerics[2],
+                numerics[3],
+                numerics[4]
+            );
+            break;
+
+        case CMD_SAVE:
+            ASSERT_ARGS(0);
+            cairo_save(state->cairo_ctx);
+            break;
+
+        case CMD_SCALE:
+            ASSERT_ARGS(1);
+            cairo_scale(state->cairo_ctx, numerics[0], numerics[0]);
+            break;
+
+        case CMD_SCALEXY:
+            ASSERT_ARGS(2);
+            cairo_scale(state->cairo_ctx, numerics[0], numerics[1]);
+            break;
+
+        case CMD_SET_COLOR:
+            ASSERT_ARGS(1);
+
+            if (state->pattern_builder != NULL)
+                cairo_pattern_destroy(state->pattern_builder);
+
+            state->pattern_builder = cairo_pattern_create_rgba(
+                colors[0][0],
+                colors[0][1],
+                colors[0][2],
+                colors[0][3]
+            );
+            break;
+
+        case CMD_SET_LINE_CAP:
+            ASSERT_ARGS(1);
+            cairo_set_line_cap(state->cairo_ctx, statement->args[0].constant);
+            break;
+
+        case CMD_SET_LINE_JOIN:
+            ASSERT_ARGS(1);
+            cairo_set_line_join(state->cairo_ctx, statement->args[0].constant);
+            break;
+
+        case CMD_SET_LINE_WIDTH:
+            ASSERT_ARGS(1);
+            cairo_set_line_width(state->cairo_ctx, numerics[0]);
+            break;
+
+        case CMD_SET_DASH:
+        case CMD_SET_DASH_OFFSET: {
+            int num;
+            double *dashes, offset, stack_buf[16];
+
+            ASSERT_ARGS(1);
+
+            num = cairo_get_dash_count(state->cairo_ctx);
+
+            if (num + 1 < FF_ARRAY_ELEMS(stack_buf)) {
+                dashes = stack_buf;
+            } else {
+                dashes = av_calloc(num + 1, sizeof(double));
+            }
+
+            cairo_get_dash(state->cairo_ctx, dashes, &offset);
+
+            if (statement->cmd == CMD_SET_DASH) {
+                dashes[num] = numerics[0];
+                num++;
+            } else {
+                offset = numerics[0];
+            }
+
+            cairo_set_dash(state->cairo_ctx, dashes, num, offset);
+
+            if (dashes != stack_buf)
+                av_freep(&dashes);
+
+            break;
+        }
+
+        case CMD_SET_HSLA:
+        case CMD_SET_RGBA: {
+            double r, g, b;
+
+            ASSERT_ARGS(4);
+
+            if (state->pattern_builder != NULL)
+                cairo_pattern_destroy(state->pattern_builder);
+
+            if (statement->cmd == CMD_SET_HSLA) {
+                hsl2rgb(numerics[0], numerics[1], numerics[2], &r, &g, &b);
+            } else {
+                r = numerics[0];
+                g = numerics[1];
+                b = numerics[2];
+            }
+
+            state->pattern_builder = cairo_pattern_create_rgba(r, g, b, numerics[3]);
+            break;
+        }
+
+        case CMD_SET_VAR: {
+            ASSERT_ARGS(2);
+
+            const int user_var = statement->args[0].constant;
+
+            av_assert0(user_var >= VAR_U0 && user_var < (VAR_U0 + USER_VAR_COUNT));
+            state->vars[user_var] = numerics[1];
+            break;
+        }
+
+        case CMD_STROKE:
+            ASSERT_ARGS(0);
+            MAY_PRESERVE(cairo_stroke);
+            break;
+
+        case CMD_S_CURVE_TO:
+        case CMD_S_CURVE_TO_REL:
+            ASSERT_ARGS(4);
+            draw_cubic_curve_to(
+                state,
+                statement->cmd == CMD_S_CURVE_TO_REL,
+                NAN,
+                NAN,
+                numerics[0],
+                numerics[1],
+                numerics[2],
+                numerics[3]
+            );
+            break;
+
+        case CMD_TRANSLATE:
+            ASSERT_ARGS(2);
+            cairo_translate(state->cairo_ctx, numerics[0], numerics[1]);
+            break;
+
+        case CMD_T_CURVE_TO:
+        case CMD_T_CURVE_TO_REL:
+            ASSERT_ARGS(2);
+            relative = statement->cmd == CMD_T_CURVE_TO_REL;
+            draw_quad_curve_to(state, relative, NAN, NAN, numerics[0], numerics[1]);
+            break;
+
+        case CMD_HORZ:
+        case CMD_HORZ_REL:
+        case CMD_VERT:
+        case CMD_VERT_REL:
+            ASSERT_ARGS(1);
+
+            if (cairo_has_current_point(state->cairo_ctx)) {
+                double d = numerics[0];
+
+                switch (statement->cmd) {
+                    case CMD_HORZ:     cx  = d; break;
+                    case CMD_VERT:     cy  = d; break;
+                    case CMD_HORZ_REL: cx += d; break;
+                    case CMD_VERT_REL: cy += d; break;
+                }
+
+                cairo_line_to(state->cairo_ctx, cx, cy);
+            }
+
+            break;
+        }
+
+        // Reflected control points will be discarded if the executed
+        // command did not update them, and it is a commands to
+        // modify the path.
+        if (state->rcp.status == RCP_UPDATED) {
+            state->rcp.status = RCP_VALID;
+        } else if (vgs_cmd_change_path(statement->cmd)) {
+            state->rcp.status = RCP_NONE;
+        }
+
+        // Check for errors in cairo.
+        if (cairo_status(state->cairo_ctx) != CAIRO_STATUS_SUCCESS) {
+            av_log(
+                state->log_ctx,
+                AV_LOG_ERROR,
+                "Error in cairo context: %s\n",
+                cairo_status_to_string(cairo_status(state->cairo_ctx))
+            );
+
+            return AVERROR(EINVAL);
+        }
+    }
+
+    return 0;
+}
+
+/*
+ * == AVClass for drawvg ==
+ *
+ * Source is parsed on the `init` function.
+ *
+ * Cairo supports a few pixel formats, but only RGB. All compatible formats are
+ * listed in the `drawvg_pix_fmts` array.
+ */
+
+typedef struct DrawVGContext {
+    const AVClass *class;
+
+    /// Equivalent to AVPixelFormat.
+    cairo_format_t cairo_format;
+
+    /// Time in seconds of the first frame.
+    double time_start;
+
+    /// Inline source.
+    uint8_t *script_text;
+
+    /// File path to load the source.
+    uint8_t *script_file;
+
+    struct VGSProgram program;
+} DrawVGContext;
+
+#define OPT(name, field, help)          \
+    {                                   \
+        name,                           \
+        help,                           \
+        offsetof(DrawVGContext, field), \
+        AV_OPT_TYPE_STRING,             \
+        { .str = NULL },                \
+        0, 0,                           \
+        AV_OPT_FLAG_FILTERING_PARAM     \
+           | AV_OPT_FLAG_VIDEO_PARAM    \
+    }
+
+static const AVOption drawvg_options[]= {
+    OPT("script", script_text, "script source to draw the graphics"),
+    OPT("s",      script_text, "script source to draw the graphics"),
+    OPT("file",   script_file, "file to load the script source"),
+    { NULL }
+};
+
+#undef OPT
+
+
+AVFILTER_DEFINE_CLASS(drawvg);
+
+static const enum AVPixelFormat drawvg_pix_fmts[] = {
+    AV_PIX_FMT_RGB32,
+    AV_PIX_FMT_0RGB32,
+    AV_PIX_FMT_RGB565,
+    AV_PIX_FMT_X2RGB10,
+    AV_PIX_FMT_NONE
+};
+
+// Return the cairo equivalent to AVPixelFormat.
+static cairo_format_t cairo_format_from_pix_fmt(
+    DrawVGContext* ctx,
+    enum AVPixelFormat format
+) {
+    // This array must have the same order of `drawvg_pix_fmts`.
+    const cairo_format_t format_map[] = {
+        CAIRO_FORMAT_ARGB32, // cairo expects pre-multiplied alpha.
+        CAIRO_FORMAT_RGB24,
+        CAIRO_FORMAT_RGB16_565,
+        CAIRO_FORMAT_RGB30,
+        CAIRO_FORMAT_INVALID,
+    };
+
+    for (int i = 0; i < FF_ARRAY_ELEMS(drawvg_pix_fmts); i++) {
+        if (drawvg_pix_fmts[i] == format)
+            return format_map[i];
+    }
+
+    const char* name = av_get_pix_fmt_name(format);
+    av_log(ctx, AV_LOG_ERROR, "Invalid pix_fmt: %s\n", name);
+
+    return CAIRO_FORMAT_INVALID;
+}
+
+static int drawvg_filter_frame(AVFilterLink *inlink, AVFrame *frame) {
+    int ret;
+    double var_t;
+    cairo_surface_t* surface;
+
+    FilterLink *inl = ff_filter_link(inlink);
+    AVFilterLink *outlink = inlink->dst->outputs[0];
+    AVFilterContext *filter_ctx = inlink->dst;
+    DrawVGContext *drawvg_ctx = filter_ctx->priv;
+
+    struct VGSEvalState eval_state;
+    vgs_eval_state_init(&eval_state, &drawvg_ctx->program, drawvg_ctx, frame);
+
+    // Draw directly on the frame data.
+    surface = cairo_image_surface_create_for_data(
+        frame->data[0],
+        drawvg_ctx->cairo_format,
+        frame->width,
+        frame->height,
+        frame->linesize[0]
+    );
+
+    if (cairo_surface_status(surface) != CAIRO_STATUS_SUCCESS) {
+        av_log(drawvg_ctx, AV_LOG_ERROR, "Failed to create cairo surface.\n");
+        return AVERROR_EXTERNAL;
+    }
+
+    eval_state.cairo_ctx = cairo_create(surface);
+
+    var_t = TS2T(frame->pts, inlink->time_base);
+
+    if (isnan(drawvg_ctx->time_start))
+        drawvg_ctx->time_start = var_t;
+
+    eval_state.vars[VAR_N] = inl->frame_count_out;
+    eval_state.vars[VAR_T] = var_t;
+    eval_state.vars[VAR_TS] = drawvg_ctx->time_start;
+    eval_state.vars[VAR_W] = inlink->w;
+    eval_state.vars[VAR_H] = inlink->h;
+    eval_state.vars[VAR_DURATION] = frame->duration * av_q2d(inlink->time_base);
+
+    eval_state.metadata = frame->metadata;
+
+    ret = vgs_eval(&eval_state, &drawvg_ctx->program);
+
+    cairo_destroy(eval_state.cairo_ctx);
+    cairo_surface_destroy(surface);
+
+    vgs_eval_state_free(&eval_state);
+
+    if (ret != 0)
+        return ret;
+
+    return ff_filter_frame(outlink, frame);
+}
+
+static int drawvg_config_props(AVFilterLink *inlink) {
+    AVFilterContext *filter_ctx = inlink->dst;
+    DrawVGContext *drawvg_ctx = filter_ctx->priv;
+
+    // Find the cairo format equivalent to the format of the frame,
+    // so cairo can draw directly on the memory already allocated.
+
+    drawvg_ctx->cairo_format = cairo_format_from_pix_fmt(drawvg_ctx, inlink->format);
+    if (drawvg_ctx->cairo_format == CAIRO_FORMAT_INVALID)
+        return AVERROR(EINVAL);
+
+    return 0;
+}
+
+static av_cold int drawvg_init(AVFilterContext *ctx) {
+    int ret;
+    struct VGSParser parser;
+    DrawVGContext *drawvg = ctx->priv;
+
+    drawvg->time_start = NAN;
+
+    if ((drawvg->script_text == NULL) == (drawvg->script_file == NULL)) {
+        av_log(ctx, AV_LOG_ERROR,
+            "Either 'source' or 'file' must be provided\n");
+
+        return AVERROR(EINVAL);
+    }
+
+    if (drawvg->script_file != NULL) {
+        ret = ff_load_textfile(
+            ctx,
+            (const char *)drawvg->script_file,
+            &drawvg->script_text,
+            NULL
+        );
+
+        if (ret != 0)
+            return ret;
+    }
+
+    vgs_parser_init(&parser, drawvg->script_text);
+
+    ret = vgs_parse(drawvg, &parser, &drawvg->program, 0);
+
+    vgs_parser_free(&parser);
+
+    return ret;
+}
+
+static av_cold void drawvg_uninit(AVFilterContext *ctx) {
+    DrawVGContext *drawvg = ctx->priv;
+    vgs_free(&drawvg->program);
+}
+
+static const AVFilterPad drawvg_inputs[] = {
+    {
+        .name         = "default",
+        .type         = AVMEDIA_TYPE_VIDEO,
+        .flags        = AVFILTERPAD_FLAG_NEEDS_WRITABLE,
+        .filter_frame = drawvg_filter_frame,
+        .config_props = drawvg_config_props,
+    },
+};
+
+const FFFilter ff_vf_drawvg = {
+    .p.name        = "drawvg",
+    .p.description = NULL_IF_CONFIG_SMALL("Draw vector graphics on top of video frames."),
+    .p.priv_class  = &drawvg_class,
+    .p.flags       = AVFILTER_FLAG_SUPPORT_TIMELINE_GENERIC,
+    .priv_size     = sizeof(DrawVGContext),
+    .init          = drawvg_init,
+    .uninit        = drawvg_uninit,
+    FILTER_INPUTS(drawvg_inputs),
+    FILTER_OUTPUTS(ff_video_default_filterpad),
+    FILTER_PIXFMTS_ARRAY(drawvg_pix_fmts),
+};
diff --git a/tests/fate/filter-video.mak b/tests/fate/filter-video.mak
index 2b41574df2..d8cea88d4c 100644
--- a/tests/fate/filter-video.mak
+++ b/tests/fate/filter-video.mak
@@ -452,6 +452,12 @@ fate-filter-fps-down-eof-pass: CMD = framecrc -lavfi testsrc2=r=7:d=3.5,fps=3:eo
 fate-filter-fps-start-drop: CMD = framecrc -lavfi testsrc2=r=7:d=3.5,fps=3:start_time=1.5
 fate-filter-fps-start-fill: CMD = framecrc -lavfi testsrc2=r=7:d=1.5,setpts=PTS+14,fps=3:start_time=1.5
 
+DRAWVG_SCRIPT_ALL = $(SRC_PATH)/tests/ref/lavf/drawvg.all
+
+FATE_FILTER-$(CONFIG_DRAWVG_FILTER) += fate-filter-drawvg-interpreter
+fate-filter-drawvg-interpreter: $(DRAWVG_SCRIPT_ALL)
+fate-filter-drawvg-interpreter: CMD = run libavfilter/tests/drawvg$(EXESUF) $(DRAWVG_SCRIPT_ALL)
+
 FATE_FILTER_SAMPLES-$(call FILTERDEMDEC, FPS SCALE, MOV, QTRLE) += fate-filter-fps-cfr fate-filter-fps
 fate-filter-fps-cfr: CMD = framecrc -auto_conversion_filters -i $(TARGET_SAMPLES)/qtrle/apple-animation-variable-fps-bug.mov -r 30 -fps_mode cfr -pix_fmt yuv420p
 fate-filter-fps:     CMD = framecrc -auto_conversion_filters -i $(TARGET_SAMPLES)/qtrle/apple-animation-variable-fps-bug.mov -vf fps=30 -pix_fmt yuv420p
@@ -602,6 +608,11 @@ fate-filter-tiltandshift-410: CMD = framecrc -c:v pgmyuv -i $(SRC) -flags +bitex
 fate-filter-tiltandshift-422: CMD = framecrc -c:v pgmyuv -i $(SRC) -flags +bitexact -vf scale=sws_flags=+accurate_rnd+bitexact,format=yuv422p,tiltandshift
 fate-filter-tiltandshift-444: CMD = framecrc -c:v pgmyuv -i $(SRC) -flags +bitexact -vf scale=sws_flags=+accurate_rnd+bitexact,format=yuv444p,tiltandshift
 
+DRAWVG_SCRIPT_LINES = $(SRC_PATH)/tests/ref/lavf/drawvg.lines
+FATE_FILTER_VSYNTH_VIDEO_FILTER-$(CONFIG_DRAWVG_FILTER) += fate-filter-drawvg-video
+fate-filter-drawvg-video: $(DRAWVG_SCRIPT_LINES)
+fate-filter-drawvg-video: CMD = video_filter scale,format=bgr0,drawvg=file=$(DRAWVG_SCRIPT_LINES)
+
 tests/pixfmts.mak: TAG = GEN
 tests/pixfmts.mak: ffmpeg$(PROGSSUF)$(EXESUF) | tests
 	$(M)printf "PIXFMTS = " > $@
diff --git a/tests/ref/fate/filter-drawvg-interpreter b/tests/ref/fate/filter-drawvg-interpreter
new file mode 100644
index 0000000000..21c6ccd848
--- /dev/null
+++ b/tests/ref/fate/filter-drawvg-interpreter
@@ -0,0 +1,130 @@
+check_sorted_cmds_array: 0 failures
+
+--- check_script: drawvg.all
+cairo_set_line_join 0
+cairo_set_line_cap 1
+cairo_move_to 0.0 0.0
+cairo_rel_line_to 0.0 0.0
+cairo_rel_line_to 1.0 1.0
+cairo_rel_line_to 2.0 2.0
+cairo_line_to -1.0 -2.0
+av_log[32]: [29:7] 1 = 1.000000 | [29:9] foo = -1.000000 | [29:13] (bar - 2) = -4.000000
+cairo_set_source lineargrad(0.0 0.0 1.0 1.0) 0.0/#ff0000ff 1.0/#0000ffff
+cairo_save
+cairo_restore
+cairo_scale 1.0 1.0
+cairo_scale 2.0 3.0
+cairo_translate 4.0 5.0
+cairo_rotate 6.0
+cairo_identity_matrix
+cairo_rectangle 1.0 2.0 3.0 4.0
+cairo_save
+cairo_translate 1.0 2.0
+cairo_new_sub_path
+cairo_arc 0.0 0.0 3.0 0.0 6.3
+cairo_close_path
+cairo_new_sub_path
+cairo_restore
+cairo_new_sub_path
+cairo_arc 150.0 150.0 50.0 3.1 4.7
+cairo_arc 450.0 150.0 50.0 4.7 6.3
+cairo_arc 450.0 450.0 50.0 0.0 1.6
+cairo_arc 150.0 450.0 50.0 1.6 3.1
+cairo_close_path
+cairo_set_source #abcdefff
+cairo_stroke
+cairo_set_source lineargrad(0.0 1.0 2.0 3.0) 0.0/#ff0000ff 1.0/#0000ffff
+cairo_stroke
+cairo_set_source radialgrad(1.0 2.0 3.0 4.0 5.0 6.0) 0.0/#000000ff 0.1/#ffffffff 0.1/#000000ff 0.2/#ffffffff
+cairo_stroke
+cairo_move_to 10.0 50.0
+cairo_curve_to 20.0 33.3 30.0 33.3 40.0 50.0
+cairo_curve_to 50.0 66.7 60.0 66.7 70.0 50.0
+cairo_curve_to 80.0 33.3 90.0 33.3 100.0 50.0
+cairo_curve_to 120.0 100.0 140.0 0.0 200.0 50.0
+cairo_set_fill_rule 0
+cairo_fill_preserve
+cairo_set_fill_rule 1
+cairo_fill_preserve
+cairo_stroke_preserve
+cairo_set_fill_rule 0
+cairo_clip_preserve
+cairo_set_fill_rule 0
+cairo_fill
+cairo_set_fill_rule 1
+cairo_fill
+cairo_set_fill_rule 0
+cairo_clip
+cairo_set_fill_rule 1
+cairo_clip
+cairo_set_dash [ -1.0 1.0 ] -2.0
+cairo_set_dash [ -1.0 2.0 ] -2.0
+cairo_set_dash [ -1.0 3.0 ] -2.0
+cairo_set_dash [ -1.0 ] 4.0
+cairo_set_dash [ ] 0.0
+cairo_move_to 1.0 2.0
+cairo_rel_line_to -1.0 -2.0
+cairo_set_source #19334c66
+cairo_set_fill_rule 0
+cairo_fill
+cairo_set_source #475b3d66
+cairo_set_fill_rule 0
+cairo_fill
+cairo_set_source #7f99b2cc
+cairo_set_fill_rule 0
+cairo_fill
+cairo_set_source #a8d7efe5
+cairo_set_fill_rule 0
+cairo_fill
+cairo_rel_line_to 1.0 3.0
+cairo_rel_line_to nan 0.0
+
+--- check_script: M 0 (1*(t+1)
+av_log[16]: Invalid token '(' at line 1, column 5: Unmatched parenthesis.
+check_script: vgs_parse = -22
+
+--- check_script: save invalid 1 2
+av_log[16]: Invalid token 'invalid' at line 1, column 6: Expected command.
+check_script: vgs_parse = -22
+
+--- check_script: setlinecap unknown m 10 20
+av_log[16]: Invalid token 'unknown' at line 1, column 12: Expected one of 'butt' 'round' 'square'.
+check_script: vgs_parse = -22
+
+--- check_script: M 0 1 2
+av_log[16]: Invalid token '<EOF>' at line 1, column 8: Expected numeric argument.
+check_script: vgs_parse = -22
+
+--- check_script: setvar ba^d 0
+av_log[16]: Invalid token 'ba^d' at line 1, column 8: Invalid variable name.
+check_script: vgs_parse = -22
+
+--- check_script: setvar cx 0
+av_log[16]: Invalid token 'cx' at line 1, column 8: Reserved variable name.
+check_script: vgs_parse = -22
+
+--- check_script:  setvar v0 0 setvar v1 1 setvar v2 2 setvar v3 3 setvar v4 4 setvar v5 5 setvar v6 6 setvar v7 7 setvar v8 8 setvar v9 9 setvar v10 10 setvar v11 11 setvar v12 12 setvar v13 13 setvar v14 14 setvar v15 15 setvar v16 16 setvar v17 17 setvar v18 18 setvar v19 19 M (v0) (v19) 1 (unknown_var)
+av_log[16]: Undefined constant or missing '(' in 'unknown_var)'
+av_log[16]: Invalid token '(unknown_var)' at line 1, column 277: Invalid expression.
+check_script: vgs_parse = -22
+
+--- check_script:  setvar v1 0 setvar v2 1 setvar v3 2 setvar v4 3 setvar v5 4 setvar v6 5 setvar v7 6 setvar v8 7 setvar v9 8 setvar v10 9 setvar v11 10 setvar v12 11 setvar v13 12 setvar v14 13 setvar v15 14 setvar v16 15 setvar v17 16 setvar v18 17 setvar v19 18 setvar v20 19 setvar v21 20
+av_log[16]: Invalid token 'v21' at line 1, column 270: Too many user variables. Can define up to 20 variables.
+check_script: vgs_parse = -22
+
+--- check_script: call a
+av_log[16]: Missing body for procedure 'a'
+
+--- check_script: proc a { call b } call a
+av_log[16]: Missing body for procedure 'b'
+
+--- check_script: proc p0 a1 a2 a3 a4 a5 a6 a7 a8 { break }
+av_log[16]: Invalid token 'a7' at line 1, column 27: Too many parameters. Limit is 6
+check_script: vgs_parse = -22
+
+--- check_script: proc p0 a1 a2 { break } call p0 break
+av_log[16]: Procedure expects 2 arguments, but received 0.
+--- check_script: proc p0 a1 a2 { break } call p0 1 2 3
+av_log[16]: Procedure expects 2 arguments, but received 3.
+--- check_script: M 0 (1 + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n + n)
+cairo_move_to 0.0 101.0
diff --git a/tests/ref/fate/filter-drawvg-video b/tests/ref/fate/filter-drawvg-video
new file mode 100644
index 0000000000..0a646f6e2e
--- /dev/null
+++ b/tests/ref/fate/filter-drawvg-video
@@ -0,0 +1 @@
+drawvg-video        caa7642950ab2fb1367bd28c287f31bd
diff --git a/tests/ref/lavf/drawvg.all b/tests/ref/lavf/drawvg.all
new file mode 100644
index 0000000000..9603c8ed8f
--- /dev/null
+++ b/tests/ref/lavf/drawvg.all
@@ -0,0 +1,100 @@
+// Script to test how drawvg instructions are translated to cairo functions,
+// for `make fate-filter-drawvg-interpreter`.
+
+// Comments.
+lineargrad 0 0 1 1
+colorstop 0 red // after a statement
+colorstop
+    1 // in the middle of a statement
+    blue
+
+
+// Constants.
+setlinejoin miter
+setlinecap round
+
+// if/repeat
+M 0 0
+repeat 10 {
+    if (eq(i,3)) { break }
+    l (i) (i)
+}
+
+// User variables.
+setvar foo -1
+setvar bar -2
+lineto foo (bar)
+
+// Print
+print 1 foo (bar - 2)
+
+// State
+save
+restore
+
+// Transformation matrix.
+scale 1
+scalexy 2 3
+translate 4 5
+rotate 6
+resetmatrix
+
+// Basic shapes
+rect 1 2 3 4
+circle 1 2 3
+roundedrect 100 100 400 400 50
+
+// Sources
+setcolor #abcdef
+stroke
+
+lineargrad 0 1 2 3
+colorstop 0 red 1 blue
+stroke
+
+radialgrad 1 2 3 4 5 6
+repeat 2 { colorstop (i/10) black ((i+1)/10) white }
+stroke
+
+// Curves. The next line should be compatible with SVG's <path>.
+M 10,50 Q 25,25 40,50 t 30,0 30,0 c 20 50 40 -50 100 0
+
+// Preserve
+preserve fill
+preserve eofill
+preserve stroke
+preserve clip
+
+// Fill/clip
+fill eofill
+clip eoclip
+
+// Dashes
+setdash 1 2 3
+setdashoffset 4
+resetdash
+
+// Procedures
+setvar a -1
+setvar b -2
+proc f2 a b { M a b break call invalid }
+proc f1 a { call f2 a 2 }
+proc f0 { call f1 1 }
+call f0
+l a b
+
+// Colors
+setrgba 0.1 0.2 0.3 0.4 fill
+sethsla 100 0.2 0.3 0.4 fill
+
+defrgba c0 0.5 0.6 0.7 0.8
+defhsla c1 200 0.7 0.8 0.9
+
+setcolor c0 fill
+setcolor c1 fill
+
+// Frame metadata
+getmetadata md0 m.a
+getmetadata md1 m.b
+getmetadata md2 m.c
+l md0 (md1 + 1) md2 0
diff --git a/tests/ref/lavf/drawvg.lines b/tests/ref/lavf/drawvg.lines
new file mode 100644
index 0000000000..e145052d50
--- /dev/null
+++ b/tests/ref/lavf/drawvg.lines
@@ -0,0 +1,10 @@
+// Render a square, for `make fate-filter-drawvg-video`.
+
+M 10 10
+l 0 10
+h 10
+v -10
+h -10
+z
+setcolor blue
+stroke
-- 
2.49.1

_______________________________________________
ffmpeg-devel mailing list -- ffmpeg-devel@ffmpeg.org
To unsubscribe send an email to ffmpeg-devel-leave@ffmpeg.org

                 reply	other threads:[~2025-09-07 22:50 UTC|newest]

Thread overview: [no followups] expand[flat|nested]  mbox.gz  Atom feed

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=175728541704.25.13938384286569916058@463a07221176 \
    --to=ffmpeg-devel@ffmpeg.org \
    --cc=code@ffmpeg.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link

Git Inbox Mirror of the ffmpeg-devel mailing list - see https://ffmpeg.org/mailman/listinfo/ffmpeg-devel

This inbox may be cloned and mirrored by anyone:

	git clone --mirror https://master.gitmailbox.com/ffmpegdev/0 ffmpegdev/git/0.git

	# If you have public-inbox 1.1+ installed, you may
	# initialize and index your mirror using the following commands:
	public-inbox-init -V2 ffmpegdev ffmpegdev/ https://master.gitmailbox.com/ffmpegdev \
		ffmpegdev@gitmailbox.com
	public-inbox-index ffmpegdev

Example config snippet for mirrors.


AGPL code for this site: git clone https://public-inbox.org/public-inbox.git