diff mbox series

[FFmpeg-devel] filters/metadata: add CSV output support

Message ID 20210224195354.60821-1-werner.robitza@gmail.com
State Superseded
Headers show
Series [FFmpeg-devel] filters/metadata: add CSV output support
Related show

Checks

Context Check Description
andriy/x86_make fail Make failed
andriy/PPC64_make warning Make failed

Commit Message

Werner Robitza Feb. 24, 2021, 7:53 p.m. UTC
This adds a new option to the metadata filter that allows outputting CSV data.
The separator can be set via another option.
Special characters are handled via escaping.

Signed-off-by: Werner Robitza <werner.robitza@gmail.com>
---
 doc/filters.texi         |  14 ++++
 libavfilter/f_metadata.c | 155 ++++++++++++++++++++++++++++++++++++---
 2 files changed, 158 insertions(+), 11 deletions(-)

Comments

Paul B Mahol Feb. 24, 2021, 8:11 p.m. UTC | #1
Why duplicating code that would give same output as ffprobe?

On Wed, Feb 24, 2021 at 9:01 PM Werner Robitza <werner.robitza@gmail.com>
wrote:

> This adds a new option to the metadata filter that allows outputting CSV
> data.
> The separator can be set via another option.
> Special characters are handled via escaping.
>
> Signed-off-by: Werner Robitza <werner.robitza@gmail.com>
> ---
>  doc/filters.texi         |  14 ++++
>  libavfilter/f_metadata.c | 155 ++++++++++++++++++++++++++++++++++++---
>  2 files changed, 158 insertions(+), 11 deletions(-)
>
> diff --git a/doc/filters.texi b/doc/filters.texi
> index 426cb158da..0b56d73565 100644
> --- a/doc/filters.texi
> +++ b/doc/filters.texi
> @@ -25134,6 +25134,20 @@ with AV_LOG_INFO loglevel.
>  @item direct
>  Reduces buffering in print mode when output is written to a URL set using
> @var{file}.
>
> +@item format
> +Set the output format for the print mode.
> +
> +@table @samp
> +@item default
> +Default output format
> +
> +@item csv
> +Comma-separated output
> +@end table
> +
> +@item csv_sep
> +Set the CSV separator (only valid for CSV format). Default is @code{,}.
> Must be
> +one character and cannot be a period (@code{.}).
>  @end table
>
>  @subsection Examples
> diff --git a/libavfilter/f_metadata.c b/libavfilter/f_metadata.c
> index 598257b15b..45f5eeddcd 100644
> --- a/libavfilter/f_metadata.c
> +++ b/libavfilter/f_metadata.c
> @@ -58,6 +58,11 @@ enum MetadataFunction {
>      METADATAF_NB
>  };
>
> +enum MetadataFormat {
> +    METADATA_FORMAT_DEFAULT,
> +    METADATA_FORMAT_CSV
> +};
> +
>  static const char *const var_names[] = {
>      "VALUE1",
>      "VALUE2",
> @@ -85,6 +90,9 @@ typedef struct MetadataContext {
>      AVIOContext* avio_context;
>      char *file_str;
>
> +    int format;
> +    char *csv_sep;
> +
>      int (*compare)(struct MetadataContext *s,
>                     const char *value1, const char *value2);
>      void (*print)(AVFilterContext *ctx, const char *msg, ...)
> av_printf_format(2, 3);
> @@ -114,6 +122,10 @@ static const AVOption filt_name##_options[] = { \
>      { "expr", "set expression for expr function", OFFSET(expr_str),
> AV_OPT_TYPE_STRING, {.str = NULL }, 0, 0, FLAGS }, \
>      { "file", "set file where to print metadata information",
> OFFSET(file_str), AV_OPT_TYPE_STRING, {.str=NULL}, 0, 0, FLAGS }, \
>      { "direct", "reduce buffering when printing to user-set file or
> pipe", OFFSET(direct), AV_OPT_TYPE_BOOL, {.i64 = 0}, 0, 1, FLAGS }, \
> +    { "format", "set output format", OFFSET(format), AV_OPT_TYPE_INT,
> {.i64 = 0 }, 0, METADATAF_NB-1, FLAGS, "format" }, \
> +    {   "default", "default format",  0, AV_OPT_TYPE_CONST, {.i64 =
> METADATA_FORMAT_DEFAULT }, 0, 0, FLAGS, "format" }, \
> +    {   "csv",     "comma-separated", 0, AV_OPT_TYPE_CONST, {.i64 =
> METADATA_FORMAT_CSV },     0, 0, FLAGS, "format" }, \
> +    { "csv_sep", "set CSV separator (only valid for CSV format)",
> OFFSET(csv_sep), AV_OPT_TYPE_STRING, {.str=","},  0, 0, FLAGS }, \
>      { NULL } \
>  }
>
> @@ -202,6 +214,58 @@ static void print_file(AVFilterContext *ctx, const
> char *msg, ...)
>      va_end(argument_list);
>  }
>
> +static void print_csv_escaped(AVFilterContext *ctx, const char *src)
> +{
> +    MetadataContext *s = ctx->priv;
> +
> +    char meta_chars[] = {s->csv_sep[0], '"', '\n', '\r', '\0'};
> +    int needs_quoting = !!src[strcspn(src, meta_chars)];
> +
> +    // allocate space for two extra quotes and possibly every char escaped
> +    char buf[strlen(src) * 2 + 2];
> +
> +    int pos = 0;
> +
> +    if (needs_quoting)
> +        buf[pos++] = '"';
> +
> +    for (int i = 0; i < strlen(src); i++) {
> +        if (src[i] == '"')
> +            buf[pos++] = '\"';
> +        buf[pos++] = src[i];
> +    }
> +
> +    if (needs_quoting)
> +        buf[pos++] = '"';
> +
> +    buf[pos] = '\0';
> +
> +    s->print(ctx, "%s", buf);
> +}
> +
> +static void csv_escape(const char *src, char **dst, const char csv_sep)
> +{
> +    char meta_chars[] = {csv_sep, '"', '\n', '\r', '\0'};
> +    int needs_quoting = !!src[strcspn(src, meta_chars)];
> +
> +    int pos = 0;
> +
> +    if (needs_quoting)
> +        *dst[pos++] = '"';
> +
> +    for (int i = 0; i < strlen(src); i++)
> +    {
> +        if (src[i] == '"')
> +            *dst[pos++] = '\"';
> +        *dst[pos++] = src[i];
> +    }
> +
> +    if (needs_quoting)
> +        *dst[pos++] = '"';
> +
> +    *dst[pos] = '\0';
> +}
> +
>  static av_cold int init(AVFilterContext *ctx)
>  {
>      MetadataContext *s = ctx->priv;
> @@ -282,6 +346,33 @@ static av_cold int init(AVFilterContext *ctx)
>              s->avio_context->direct = AVIO_FLAG_DIRECT;
>      }
>
> +    if (s->format == METADATA_FORMAT_CSV) {
> +        if (strlen(s->csv_sep) == 0) {
> +            av_log(ctx, AV_LOG_ERROR,
> +                   "No CSV separator supplied\n");
> +            return AVERROR(EINVAL);
> +        }
> +        if (strlen(s->csv_sep) > 1) {
> +            av_log(ctx, AV_LOG_ERROR,
> +                   "CSV separator must be one character only\n");
> +            return AVERROR(EINVAL);
> +        }
> +        if (s->csv_sep[0] == '.') {
> +            av_log(ctx, AV_LOG_ERROR,
> +                   "CSV separator cannot be a single period ('.')\n");
> +            return AVERROR(EINVAL);
> +        }
> +        s->print(
> +            ctx,
> +            "%s%s%s%s%s%s%s%s%s\n",
> +            "frame", s->csv_sep,
> +            "pts", s->csv_sep,
> +            "pts_time", s->csv_sep,
> +            "key", s->csv_sep,
> +            "value"
> +        );
> +    }
> +
>      return 0;
>  }
>
> @@ -332,17 +423,59 @@ static int filter_frame(AVFilterLink *inlink,
> AVFrame *frame)
>          }
>          return ff_filter_frame(outlink, frame);
>      case METADATA_PRINT:
> -        if (!s->key && e) {
> -            s->print(ctx, "frame:%-4"PRId64" pts:%-7s pts_time:%s\n",
> -                     inlink->frame_count_out, av_ts2str(frame->pts),
> av_ts2timestr(frame->pts, &inlink->time_base));
> -            s->print(ctx, "%s=%s\n", e->key, e->value);
> -            while ((e = av_dict_get(*metadata, "", e,
> AV_DICT_IGNORE_SUFFIX)) != NULL) {
> -                s->print(ctx, "%s=%s\n", e->key, e->value);
> -            }
> -        } else if (e && e->value && (!s->value || (e->value &&
> s->compare(s, e->value, s->value)))) {
> -            s->print(ctx, "frame:%-4"PRId64" pts:%-7s pts_time:%s\n",
> -                     inlink->frame_count_out, av_ts2str(frame->pts),
> av_ts2timestr(frame->pts, &inlink->time_base));
> -            s->print(ctx, "%s=%s\n", s->key, e->value);
> +        switch(s->format) {
> +            case METADATA_FORMAT_DEFAULT:
> +                if (!s->key && e) {
> +                    s->print(ctx, "frame:%-4"PRId64" pts:%-7s
> pts_time:%s\n",
> +                            inlink->frame_count_out,
> av_ts2str(frame->pts), av_ts2timestr(frame->pts, &inlink->time_base));
> +                    s->print(ctx, "%s=%s\n", e->key, e->value);
> +                    while ((e = av_dict_get(*metadata, "", e,
> AV_DICT_IGNORE_SUFFIX)) != NULL) {
> +                        s->print(ctx, "%s=%s\n", e->key, e->value);
> +                    }
> +                } else if (e && e->value && (!s->value || (e->value &&
> s->compare(s, e->value, s->value)))) {
> +                    s->print(ctx, "frame:%-4"PRId64" pts:%-7s
> pts_time:%s\n",
> +                            inlink->frame_count_out,
> av_ts2str(frame->pts), av_ts2timestr(frame->pts, &inlink->time_base));
> +                    s->print(ctx, "%s=%s\n", s->key, e->value);
> +                }
> +                break;
> +            case METADATA_FORMAT_CSV:
> +                if (!s->key && e) {
> +                    s->print(
> +                        ctx, "%"PRId64"%s%s%s%s%s",
> +                        inlink->frame_count_out, s->csv_sep,
> +                        av_ts2str(frame->pts), s->csv_sep,
> +                        av_ts2timestr(frame->pts, &inlink->time_base),
> s->csv_sep
> +                    );
> +                    print_csv_escaped(ctx, e->key);
> +                    s->print(ctx, "%s", s->csv_sep);
> +                    print_csv_escaped(ctx, e->value);
> +                    while ((e = av_dict_get(*metadata, "", e,
> AV_DICT_IGNORE_SUFFIX)) != NULL) {
> +                        s->print(
> +                            ctx, "%"PRId64"%s%s%s%s%s",
> +                            inlink->frame_count_out, s->csv_sep,
> +                            av_ts2str(frame->pts), s->csv_sep,
> +                            av_ts2timestr(frame->pts,
> &inlink->time_base), s->csv_sep
> +                        );
> +                        print_csv_escaped(ctx, e->key);
> +                        s->print(ctx, "%s", s->csv_sep);
> +                        print_csv_escaped(ctx, e->value);
> +                        s->print(ctx, "\n");
> +                    }
> +                } else if (e && e->value && (!s->value || (e->value &&
> s->compare(s, e->value, s->value)))) {
> +                    s->print(
> +                        ctx, "%"PRId64"%s%s%s%s%s",
> +                        inlink->frame_count_out, s->csv_sep,
> +                        av_ts2str(frame->pts), s->csv_sep,
> +                        av_ts2timestr(frame->pts, &inlink->time_base),
> s->csv_sep
> +                    );
> +                    print_csv_escaped(ctx, e->key);
> +                    s->print(ctx, "%s", s->csv_sep);
> +                    print_csv_escaped(ctx, e->value);
> +                    s->print(ctx, "\n");
> +                }
> +                break;
> +            default:
> +                av_assert0(0);
>          }
>          return ff_filter_frame(outlink, frame);
>      case METADATA_DELETE:
> --
> 2.30.0
>
> _______________________________________________
> ffmpeg-devel mailing list
> ffmpeg-devel@ffmpeg.org
> https://ffmpeg.org/mailman/listinfo/ffmpeg-devel
>
> To unsubscribe, visit link above, or email
> ffmpeg-devel-request@ffmpeg.org with subject "unsubscribe".
Werner Robitza Feb. 24, 2021, 8:47 p.m. UTC | #2
On Wed, Feb 24, 2021 at 9:11 PM Paul B Mahol <onemda@gmail.com> wrote:
>
> Why duplicating code that would give same output as ffprobe?

I was told to here:
http://ffmpeg.org/pipermail/ffmpeg-devel/2021-February/275510.html

Could please show an example on how to achieve this with ffprobe?
Nicolas George Feb. 24, 2021, 8:51 p.m. UTC | #3
Werner Robitza (12021-02-24):
> I was told to here:
> http://ffmpeg.org/pipermail/ffmpeg-devel/2021-February/275510.html

I never told you to duplicate code. Code duplication is almost always a
motive for rejection.

If you are tempted to duplicate code, what you need to do is to share it
between the various places that use it. Making it a proper API if
relevant.

Ideally, all the writers of ffprobe should be turned into a single
libavutil API for use in all components that need to output data.

Regards,
Werner Robitza Feb. 24, 2021, 9 p.m. UTC | #4
On Wed, Feb 24, 2021 at 9:51 PM Nicolas George <george@nsup.org> wrote:
> I never told you to duplicate code. Code duplication is almost always a
> motive for rejection.
>
> If you are tempted to duplicate code, what you need to do is to share it
> between the various places that use it. Making it a proper API if
> relevant.

I didn't really duplicate anything; this was mostly from scratch. The
case could be made that a function for escaping CSV could be shared.
There are at least two such functions already in the code base.

> Ideally, all the writers of ffprobe should be turned into a single
> libavutil API for use in all components that need to output data.

That'd make sense.
Paul B Mahol Feb. 25, 2021, 12:32 a.m. UTC | #5
On Wed, Feb 24, 2021 at 9:48 PM Werner Robitza <werner.robitza@gmail.com>
wrote:

> On Wed, Feb 24, 2021 at 9:11 PM Paul B Mahol <onemda@gmail.com> wrote:
> >
> > Why duplicating code that would give same output as ffprobe?
>
> I was told to here:
> http://ffmpeg.org/pipermail/ffmpeg-devel/2021-February/275510.html
>
> Could please show an example on how to achieve this with ffprobe?
>

See same thread where I replied or use search.
Werner Robitza Feb. 25, 2021, 8:28 a.m. UTC | #6
On Thu, Feb 25, 2021 at 1:32 AM Paul B Mahol <onemda@gmail.com> wrote:
> On Wed, Feb 24, 2021 at 9:48 PM Werner Robitza <werner.robitza@gmail.com> wrote:
>> Could please show an example on how to achieve this with ffprobe?
> See same thread where I replied or use search.

If you are referring to:

-vf your_filter,metadata=mode=print

That does not work in ffprobe.

For instance, using the patch I submitted, I can get easy to parse output like:

$ ffmpeg -f lavfi -i testsrc -frames:v 1 -filter:v
signalstats,metadata=mode=print:format=csv -f null /dev/null
frame,pts,pts_time,key,value
0,0,0,lavfi.signalstats.YMIN,4
…

How would I get the same with ffprobe?
Nicolas George Feb. 25, 2021, 10:25 a.m. UTC | #7
Werner Robitza (12021-02-25):
> If you are referring to:
> 
> -vf your_filter,metadata=mode=print
> 
> That does not work in ffprobe.

Of course, since ffprobe does the printing for you.

> For instance, using the patch I submitted, I can get easy to parse output like:

Parsing the standard output of any program is bad design and bound to
break at some point.

Also, I wonder why you chose CSV, which is one of the worse possible
formats.

Regards,
Nicolas George Feb. 25, 2021, 10:26 a.m. UTC | #8
Werner Robitza (12021-02-24):
> I didn't really duplicate anything; this was mostly from scratch. The
> case could be made that a function for escaping CSV could be shared.

The variable names seemed quite similar. Anyway, duplicating a feature
is just as bad as duplicating code.

> There are at least two such functions already in the code base.

We should not have accepted the second one like that. It does not
constitute a precedent.

Regards,
Werner Robitza Feb. 25, 2021, 11:43 a.m. UTC | #9
On Thu, Feb 25, 2021 at 11:25 AM Nicolas George <george@nsup.org> wrote:
>
> Werner Robitza (12021-02-25):
> > If you are referring to:
> >
> > -vf your_filter,metadata=mode=print
> >
> > That does not work in ffprobe.
>
> Of course, since ffprobe does the printing for you.

At the risk of repeating the same question, could you please show a
command with ffprobe that achieves the same kind of parsable output
that this patch generates?

After fiddling around a bit, when I run:

ffprobe -loglevel error -f lavfi -i "testsrc,signalstats"
-show_entries tags -of csv=nk=0 | head -n 1

I get a line like:

packet,tag:lavfi.signalstats.YMIN=16,tag:lavfi.signalstats.YLOW=16,…

which is not semantically meaningful, since both keys and values are
contained in the individual cells. This makes parsing incredibly more
complex than it has to be.

> > For instance, using the patch I submitted, I can get easy to parse output like:
>
> Parsing the standard output of any program is bad design and bound to
> break at some point.

That was just an example on the CLI. The filter has a file option with
which you can output to a well-formatted CSV file, so parsing is not
an issue there.

> Also, I wonder why you chose CSV, which is one of the worse possible
> formats.

I feel this is only tangential to the discussion, but: It's
well-defined, easy to parse by numerous libraries, can be modified
easily, and is a de-facto standard for sharing data in certain
communities (. It wouldn't even occur to me why you'd want another
format for certain kinds of applications. In particular with data that
isn't nested, it's much easier to use in data analysis procedures than
JSON or others.
Werner Robitza Feb. 25, 2021, 11:45 a.m. UTC | #10
On Thu, Feb 25, 2021 at 11:26 AM Nicolas George <george@nsup.org> wrote:
> Werner Robitza (12021-02-24):
> > I didn't really duplicate anything; this was mostly from scratch. The
> > case could be made that a function for escaping CSV could be shared.
>
> The variable names seemed quite similar. Anyway, duplicating a feature
> is just as bad as duplicating code.

Would you accept this patch if this particular function was extracted
to an API and re-used by ffprobe? (And potentially segment.c …)
Nicolas George Feb. 25, 2021, 11:47 a.m. UTC | #11
Werner Robitza (12021-02-25):
> After fiddling around a bit, when I run:
> 
> ffprobe -loglevel error -f lavfi -i "testsrc,signalstats"
> -show_entries tags -of csv=nk=0 | head -n 1
> 
> I get a line like:
> 
> packet,tag:lavfi.signalstats.YMIN=16,tag:lavfi.signalstats.YLOW=16,…

I doubt ffprobe did output an ellipsis character on its own.

For simpler parsing, use a different writer than csv. I would recommend
flat.

Regards,
Nicolas George Feb. 25, 2021, 11:49 a.m. UTC | #12
Werner Robitza (12021-02-25):
> Would you accept this patch if this particular function was extracted
> to an API and re-used by ffprobe? (And potentially segment.c …)

Yes, but I must warn you that the constraints on a libavutil API are
stronger than the constraints on helper functions in ffprobe.

This is why I re-started the following discussion:

https://ffmpeg.org/pipermail/ffmpeg-devel/2021-February/276855.html

You can weigh in.

Regards,
Werner Robitza Feb. 25, 2021, 11:58 a.m. UTC | #13
On Thu, Feb 25, 2021 at 12:47 PM Nicolas George <george@nsup.org> wrote:
> Werner Robitza (12021-02-25):
> > After fiddling around a bit, when I run:
> >
> > ffprobe -loglevel error -f lavfi -i "testsrc,signalstats"
> > -show_entries tags -of csv=nk=0 | head -n 1
> >
> > I get a line like:
> >
> > packet,tag:lavfi.signalstats.YMIN=16,tag:lavfi.signalstats.YLOW=16,…
>
> I doubt ffprobe did output an ellipsis character on its own.

That's why I wrote "like" – I am sure you were able to interpret the
meaning of the ellipsis in this example.

> For simpler parsing, use a different writer than csv. I would recommend
> flat.

That seems to be a custom format, so parsing is inherently more
complex, and there's no support for simply loading this in whatever
tool you have for data analysis (Excel, MATLAB, R, Python, …). Hence
the usefulness of semantically meaningfully structured CSV.

I guess I could live with replacing the "flat" output dots with
commas, and the equal sign with another comma, but that's bound to
break with some metadata value contents.
diff mbox series

Patch

diff --git a/doc/filters.texi b/doc/filters.texi
index 426cb158da..0b56d73565 100644
--- a/doc/filters.texi
+++ b/doc/filters.texi
@@ -25134,6 +25134,20 @@  with AV_LOG_INFO loglevel.
 @item direct
 Reduces buffering in print mode when output is written to a URL set using @var{file}.
 
+@item format
+Set the output format for the print mode.
+
+@table @samp
+@item default
+Default output format
+
+@item csv
+Comma-separated output
+@end table
+
+@item csv_sep
+Set the CSV separator (only valid for CSV format). Default is @code{,}. Must be
+one character and cannot be a period (@code{.}).
 @end table
 
 @subsection Examples
diff --git a/libavfilter/f_metadata.c b/libavfilter/f_metadata.c
index 598257b15b..45f5eeddcd 100644
--- a/libavfilter/f_metadata.c
+++ b/libavfilter/f_metadata.c
@@ -58,6 +58,11 @@  enum MetadataFunction {
     METADATAF_NB
 };
 
+enum MetadataFormat {
+    METADATA_FORMAT_DEFAULT,
+    METADATA_FORMAT_CSV
+};
+
 static const char *const var_names[] = {
     "VALUE1",
     "VALUE2",
@@ -85,6 +90,9 @@  typedef struct MetadataContext {
     AVIOContext* avio_context;
     char *file_str;
 
+    int format;
+    char *csv_sep;
+
     int (*compare)(struct MetadataContext *s,
                    const char *value1, const char *value2);
     void (*print)(AVFilterContext *ctx, const char *msg, ...) av_printf_format(2, 3);
@@ -114,6 +122,10 @@  static const AVOption filt_name##_options[] = { \
     { "expr", "set expression for expr function", OFFSET(expr_str), AV_OPT_TYPE_STRING, {.str = NULL }, 0, 0, FLAGS }, \
     { "file", "set file where to print metadata information", OFFSET(file_str), AV_OPT_TYPE_STRING, {.str=NULL}, 0, 0, FLAGS }, \
     { "direct", "reduce buffering when printing to user-set file or pipe", OFFSET(direct), AV_OPT_TYPE_BOOL, {.i64 = 0}, 0, 1, FLAGS }, \
+    { "format", "set output format", OFFSET(format), AV_OPT_TYPE_INT, {.i64 = 0 }, 0, METADATAF_NB-1, FLAGS, "format" }, \
+    {   "default", "default format",  0, AV_OPT_TYPE_CONST, {.i64 = METADATA_FORMAT_DEFAULT }, 0, 0, FLAGS, "format" }, \
+    {   "csv",     "comma-separated", 0, AV_OPT_TYPE_CONST, {.i64 = METADATA_FORMAT_CSV },     0, 0, FLAGS, "format" }, \
+    { "csv_sep", "set CSV separator (only valid for CSV format)", OFFSET(csv_sep), AV_OPT_TYPE_STRING, {.str=","},  0, 0, FLAGS }, \
     { NULL } \
 }
 
@@ -202,6 +214,58 @@  static void print_file(AVFilterContext *ctx, const char *msg, ...)
     va_end(argument_list);
 }
 
+static void print_csv_escaped(AVFilterContext *ctx, const char *src)
+{
+    MetadataContext *s = ctx->priv;
+
+    char meta_chars[] = {s->csv_sep[0], '"', '\n', '\r', '\0'};
+    int needs_quoting = !!src[strcspn(src, meta_chars)];
+
+    // allocate space for two extra quotes and possibly every char escaped
+    char buf[strlen(src) * 2 + 2];
+
+    int pos = 0;
+
+    if (needs_quoting)
+        buf[pos++] = '"';
+
+    for (int i = 0; i < strlen(src); i++) {
+        if (src[i] == '"')
+            buf[pos++] = '\"';
+        buf[pos++] = src[i];
+    }
+
+    if (needs_quoting)
+        buf[pos++] = '"';
+
+    buf[pos] = '\0';
+
+    s->print(ctx, "%s", buf);
+}
+
+static void csv_escape(const char *src, char **dst, const char csv_sep)
+{
+    char meta_chars[] = {csv_sep, '"', '\n', '\r', '\0'};
+    int needs_quoting = !!src[strcspn(src, meta_chars)];
+
+    int pos = 0;
+
+    if (needs_quoting)
+        *dst[pos++] = '"';
+
+    for (int i = 0; i < strlen(src); i++)
+    {
+        if (src[i] == '"')
+            *dst[pos++] = '\"';
+        *dst[pos++] = src[i];
+    }
+
+    if (needs_quoting)
+        *dst[pos++] = '"';
+
+    *dst[pos] = '\0';
+}
+
 static av_cold int init(AVFilterContext *ctx)
 {
     MetadataContext *s = ctx->priv;
@@ -282,6 +346,33 @@  static av_cold int init(AVFilterContext *ctx)
             s->avio_context->direct = AVIO_FLAG_DIRECT;
     }
 
+    if (s->format == METADATA_FORMAT_CSV) {
+        if (strlen(s->csv_sep) == 0) {
+            av_log(ctx, AV_LOG_ERROR,
+                   "No CSV separator supplied\n");
+            return AVERROR(EINVAL);
+        }
+        if (strlen(s->csv_sep) > 1) {
+            av_log(ctx, AV_LOG_ERROR,
+                   "CSV separator must be one character only\n");
+            return AVERROR(EINVAL);
+        }
+        if (s->csv_sep[0] == '.') {
+            av_log(ctx, AV_LOG_ERROR,
+                   "CSV separator cannot be a single period ('.')\n");
+            return AVERROR(EINVAL);
+        }
+        s->print(
+            ctx,
+            "%s%s%s%s%s%s%s%s%s\n",
+            "frame", s->csv_sep,
+            "pts", s->csv_sep,
+            "pts_time", s->csv_sep,
+            "key", s->csv_sep,
+            "value"
+        );
+    }
+
     return 0;
 }
 
@@ -332,17 +423,59 @@  static int filter_frame(AVFilterLink *inlink, AVFrame *frame)
         }
         return ff_filter_frame(outlink, frame);
     case METADATA_PRINT:
-        if (!s->key && e) {
-            s->print(ctx, "frame:%-4"PRId64" pts:%-7s pts_time:%s\n",
-                     inlink->frame_count_out, av_ts2str(frame->pts), av_ts2timestr(frame->pts, &inlink->time_base));
-            s->print(ctx, "%s=%s\n", e->key, e->value);
-            while ((e = av_dict_get(*metadata, "", e, AV_DICT_IGNORE_SUFFIX)) != NULL) {
-                s->print(ctx, "%s=%s\n", e->key, e->value);
-            }
-        } else if (e && e->value && (!s->value || (e->value && s->compare(s, e->value, s->value)))) {
-            s->print(ctx, "frame:%-4"PRId64" pts:%-7s pts_time:%s\n",
-                     inlink->frame_count_out, av_ts2str(frame->pts), av_ts2timestr(frame->pts, &inlink->time_base));
-            s->print(ctx, "%s=%s\n", s->key, e->value);
+        switch(s->format) {
+            case METADATA_FORMAT_DEFAULT:
+                if (!s->key && e) {
+                    s->print(ctx, "frame:%-4"PRId64" pts:%-7s pts_time:%s\n",
+                            inlink->frame_count_out, av_ts2str(frame->pts), av_ts2timestr(frame->pts, &inlink->time_base));
+                    s->print(ctx, "%s=%s\n", e->key, e->value);
+                    while ((e = av_dict_get(*metadata, "", e, AV_DICT_IGNORE_SUFFIX)) != NULL) {
+                        s->print(ctx, "%s=%s\n", e->key, e->value);
+                    }
+                } else if (e && e->value && (!s->value || (e->value && s->compare(s, e->value, s->value)))) {
+                    s->print(ctx, "frame:%-4"PRId64" pts:%-7s pts_time:%s\n",
+                            inlink->frame_count_out, av_ts2str(frame->pts), av_ts2timestr(frame->pts, &inlink->time_base));
+                    s->print(ctx, "%s=%s\n", s->key, e->value);
+                }
+                break;
+            case METADATA_FORMAT_CSV:
+                if (!s->key && e) {
+                    s->print(
+                        ctx, "%"PRId64"%s%s%s%s%s",
+                        inlink->frame_count_out, s->csv_sep,
+                        av_ts2str(frame->pts), s->csv_sep,
+                        av_ts2timestr(frame->pts, &inlink->time_base), s->csv_sep
+                    );
+                    print_csv_escaped(ctx, e->key);
+                    s->print(ctx, "%s", s->csv_sep);
+                    print_csv_escaped(ctx, e->value);
+                    while ((e = av_dict_get(*metadata, "", e, AV_DICT_IGNORE_SUFFIX)) != NULL) {
+                        s->print(
+                            ctx, "%"PRId64"%s%s%s%s%s",
+                            inlink->frame_count_out, s->csv_sep,
+                            av_ts2str(frame->pts), s->csv_sep,
+                            av_ts2timestr(frame->pts, &inlink->time_base), s->csv_sep
+                        );
+                        print_csv_escaped(ctx, e->key);
+                        s->print(ctx, "%s", s->csv_sep);
+                        print_csv_escaped(ctx, e->value);
+                        s->print(ctx, "\n");
+                    }
+                } else if (e && e->value && (!s->value || (e->value && s->compare(s, e->value, s->value)))) {
+                    s->print(
+                        ctx, "%"PRId64"%s%s%s%s%s",
+                        inlink->frame_count_out, s->csv_sep,
+                        av_ts2str(frame->pts), s->csv_sep,
+                        av_ts2timestr(frame->pts, &inlink->time_base), s->csv_sep
+                    );
+                    print_csv_escaped(ctx, e->key);
+                    s->print(ctx, "%s", s->csv_sep);
+                    print_csv_escaped(ctx, e->value);
+                    s->print(ctx, "\n");
+                }
+                break;
+            default:
+                av_assert0(0);
         }
         return ff_filter_frame(outlink, frame);
     case METADATA_DELETE: