", sec_ctx->context_id, section->name);
+ } else {
+ av_log(tfc, AV_LOG_ERROR, "Unable to write subgraph start. Missing id field. Section: %s", section->name);
+ }
+
+ mmc->section_data[tfc->level].subgraph_start_incomplete = 1;
+ set_str(&mmc->section_data[tfc->level].section_id, sec_ctx->context_id);
+ }
+
+ if (section->flags & AV_TEXTFORMAT_SECTION_FLAG_IS_SHAPE) {
+
+ av_bprint_clear(buf);
+ writer_put_str(tfc, "\n");
+
+ mmc->indent_level++;
+
+ if (sec_ctx->context_id) {
+
+ set_str(&mmc->section_data[tfc->level].section_id, sec_ctx->context_id);
+
+ switch (mmc->diagram_config->diagram_type) {
+ case AV_DIAGRAMTYPE_GRAPH:
+ if (sec_ctx->context_flags & 1) {
+
+ MM_INDENT();
+ writer_printf(tfc, "%s@{ shape: text, label: \"", sec_ctx->context_id);
+ mmc->section_data[tfc->level].current_is_textblock = 1;
+ } else if (sec_ctx->context_flags & 2) {
+
+ MM_INDENT();
+ writer_printf(tfc, "%s([\"", sec_ctx->context_id);
+ mmc->section_data[tfc->level].current_is_stadium = 1;
+ } else {
+ MM_INDENT();
+ writer_printf(tfc, "%s(\"", sec_ctx->context_id);
+ }
+
+ break;
+ case AV_DIAGRAMTYPE_ENTITYRELATIONSHIP:
+ MM_INDENT();
+ writer_printf(tfc, "%s {\n", sec_ctx->context_id);
+ break;
+ }
+
+ } else {
+ av_log(tfc, AV_LOG_ERROR, "Unable to write shape start. Missing id field. Section: %s", section->name);
+ }
+
+ set_str(&mmc->section_data[tfc->level].section_id, sec_ctx->context_id);
+ }
+
+
+ if (section->flags & AV_TEXTFORMAT_SECTION_PRINT_TAGS) {
+
+ if (sec_ctx && sec_ctx->context_type)
+ writer_printf(tfc, "
", section->name, sec_ctx->context_type);
+ else
+ writer_printf(tfc, "
", section->name);
+ }
+
+
+ if (section->flags & AV_TEXTFORMAT_SECTION_FLAG_HAS_LINKS) {
+
+ av_bprint_clear(buf);
+ mmc->nb_link_captions[tfc->level] = 0;
+
+ if (sec_ctx && sec_ctx->context_type)
+ set_str(&mmc->section_data[tfc->level].section_type, sec_ctx->context_type);
+
+ ////if (section->flags & AV_TEXTFORMAT_SECTION_FLAG_HAS_TYPE) {
+ //// AVBPrint buf;
+ //// av_bprint_init(&buf, 1, AV_BPRINT_SIZE_UNLIMITED);
+ //// av_bprint_escape(&buf, section->get_type(data), NULL,
+ //// AV_ESCAPE_MODE_XML, AV_ESCAPE_FLAG_XML_DOUBLE_QUOTES);
+ //// writer_printf(tfc, " type=\"%s\"", buf.str);
+ }
+}
+
+static void mermaid_print_section_footer(AVTextFormatContext *tfc)
+{
+ MermaidContext *mmc = tfc->priv;
+ const AVTextFormatSection *section = tf_get_section(tfc, tfc->level);
+
+ if (!section)
+ return;
+ AVBPrint *buf = &tfc->section_pbuf[tfc->level];
+ struct section_data sec_data = mmc->section_data[tfc->level];
+
+ if (section->flags & AV_TEXTFORMAT_SECTION_PRINT_TAGS)
+ writer_put_str(tfc, "
");
+
+ if (section->flags & AV_TEXTFORMAT_SECTION_FLAG_IS_SHAPE) {
+
+ switch (mmc->diagram_config->diagram_type) {
+ case AV_DIAGRAMTYPE_GRAPH:
+
+ if (sec_data.current_is_textblock) {
+ writer_printf(tfc, "\"}\n", section->name);
+
+ if (sec_data.section_id) {
+ MM_INDENT();
+ writer_put_str(tfc, "class ");
+ writer_put_str(tfc, sec_data.section_id);
+ writer_put_str(tfc, " ff-");
+ writer_put_str(tfc, section->name);
+ writer_put_str(tfc, "\n");
+ }
+ } else if (sec_data.current_is_stadium) {
+ writer_printf(tfc, "\"]):::ff-%s\n", section->name);
+ } else {
+ writer_printf(tfc, "\"):::ff-%s\n", section->name);
+ }
+
+ break;
+ case AV_DIAGRAMTYPE_ENTITYRELATIONSHIP:
+ MM_INDENT();
+ writer_put_str(tfc, "}\n\n");
+ break;
+ }
+
+ mmc->indent_level--;
+
+ } else if ((section->flags & AV_TEXTFORMAT_SECTION_FLAG_IS_SUBGRAPH)) {
+
+ mermaid_subgraph_complete_start(mmc, tfc, tfc->level);
+
+ MM_INDENT();
+ writer_put_str(tfc, "end\n");
+
+ if (sec_data.section_id) {
+ MM_INDENT();
+ writer_put_str(tfc, "class ");
+ writer_put_str(tfc, sec_data.section_id);
+ writer_put_str(tfc, " ff-");
+ writer_put_str(tfc, section->name);
+ writer_put_str(tfc, "\n");
+ }
+
+ mmc->indent_level--;
+ }
+
+ if ((section->flags & AV_TEXTFORMAT_SECTION_FLAG_HAS_LINKS))
+ if (sec_data.src_id && sec_data.dest_id
+ && !has_link_pair(tfc, sec_data.src_id, sec_data.dest_id))
+ switch (mmc->diagram_config->diagram_type) {
+ case AV_DIAGRAMTYPE_GRAPH:
+
+ if (sec_data.section_type && mmc->enable_link_colors)
+ av_bprintf(&mmc->link_buf, "\n %s %s-%s-%s@==", sec_data.src_id, sec_data.section_type, sec_data.src_id, sec_data.dest_id);
+ else
+ av_bprintf(&mmc->link_buf, "\n %s ==", sec_data.src_id);
+
+ if (buf->len > 0) {
+ av_bprintf(&mmc->link_buf, " \"%s", buf->str);
+
+ for (unsigned i = 0; i < mmc->nb_link_captions[tfc->level]; i++)
+ av_bprintf(&mmc->link_buf, "
");
+
+ av_bprintf(&mmc->link_buf, "\" ==");
+ }
+
+ av_bprintf(&mmc->link_buf, "> %s", sec_data.dest_id);
+
+ break;
+ case AV_DIAGRAMTYPE_ENTITYRELATIONSHIP:
+
+
+ av_bprintf(&mmc->link_buf, "\n %s", sec_data.src_id);
+
+ switch (sec_data.link_type) {
+ case AV_TEXTFORMAT_LINKTYPE_ONETOMANY:
+ av_bprintf(&mmc->link_buf, "%s", " ||--o{ ");
+ break;
+ case AV_TEXTFORMAT_LINKTYPE_MANYTOONE:
+ av_bprintf(&mmc->link_buf, "%s", " }o--|| ");
+ break;
+ case AV_TEXTFORMAT_LINKTYPE_ONETOONE:
+ av_bprintf(&mmc->link_buf, "%s", " ||--|| ");
+ break;
+ case AV_TEXTFORMAT_LINKTYPE_MANYTOMANY:
+ av_bprintf(&mmc->link_buf, "%s", " }o--o{ ");
+ break;
+ default:
+ av_bprintf(&mmc->link_buf, "%s", " ||--|| ");
+ break;
+ }
+
+ av_bprintf(&mmc->link_buf, "%s : \"\"", sec_data.dest_id);
+
+ break;
+ }
+
+ if (tfc->level == 0) {
+
+ writer_put_str(tfc, "\n");
+ if (mmc->create_html) {
+ char *token_pos = av_stristr(mmc->diagram_config->html_template, "__###__");
+ if (!token_pos) {
+ av_log(tfc, AV_LOG_ERROR, "Unable to locate the required token (__###__) in the html template.");
+ return;
+ }
+ token_pos += strlen("__###__");
+ writer_put_str(tfc, token_pos);
+ }
+ }
+
+ if (tfc->level == 1) {
+
+ if (mmc->link_buf.len > 0) {
+ writer_put_str(tfc, mmc->link_buf.str);
+ av_bprint_clear(&mmc->link_buf);
+ }
+
+ writer_put_str(tfc, "\n");
+ }
+}
+
+static void mermaid_print_value(AVTextFormatContext *tfc, const char *key,
+ const char *str, int64_t num, const int is_int)
+{
+ MermaidContext *mmc = tfc->priv;
+ const AVTextFormatSection *section = tf_get_section(tfc, tfc->level);
+
+ if (!section)
+ return;
+
+ AVBPrint *buf = &tfc->section_pbuf[tfc->level];
+ struct section_data sec_data = mmc->section_data[tfc->level];
+ int exit = 0;
+
+ if (section->id_key && !strcmp(section->id_key, key)) {
+ set_str(&mmc->section_data[tfc->level].section_id, str);
+ exit = 1;
+ }
+
+ if (section->dest_id_key && !strcmp(section->dest_id_key, key)) {
+ set_str(&mmc->section_data[tfc->level].dest_id, str);
+ exit = 1;
+ }
+
+ if (section->src_id_key && !strcmp(section->src_id_key, key)) {
+ set_str(&mmc->section_data[tfc->level].src_id, str);
+ exit = 1;
+ }
+
+ if (section->linktype_key && !strcmp(section->linktype_key, key)) {
+ mmc->section_data[tfc->level].link_type = (AVTextFormatLinkType)num;
+ exit = 1;
+ }
+
+ if (exit)
+ return;
+
+ if ((section->flags & (AV_TEXTFORMAT_SECTION_FLAG_IS_SHAPE | AV_TEXTFORMAT_SECTION_PRINT_TAGS))
+ || (section->flags & AV_TEXTFORMAT_SECTION_FLAG_IS_SUBGRAPH && sec_data.subgraph_start_incomplete)) {
+ switch (mmc->diagram_config->diagram_type) {
+ case AV_DIAGRAMTYPE_GRAPH:
+
+ if (is_int) {
+ writer_printf(tfc, "
%s: %"PRId64"", key, key, num);
+ } else {
+ const char *tmp = av_strireplace(str, "\"", "'");
+ writer_printf(tfc, "
%s", key, tmp);
+ av_freep(&tmp);
+ }
+
+ break;
+ case AV_DIAGRAMTYPE_ENTITYRELATIONSHIP:
+
+ if (!is_int && str)
+ {
+ const char *col_type;
+
+ if (key[0] == '_')
+ return;
+
+ if (sec_data.section_id && !strcmp(str, sec_data.section_id))
+ col_type = "PK";
+ else if (sec_data.dest_id && !strcmp(str, sec_data.dest_id))
+ col_type = "FK";
+ else if (sec_data.src_id && !strcmp(str, sec_data.src_id))
+ col_type = "FK";
+ else
+ col_type = "";
+
+ MM_INDENT();
+
+ writer_printf(tfc, " %s %s %s\n", key, str, col_type);
+ }
+ break;
+ }
+
+ } else if (section->flags & AV_TEXTFORMAT_SECTION_FLAG_HAS_LINKS) {
+ if (buf->len > 0)
+ av_bprintf(buf, "%s", "
");
+
+ av_bprintf(buf, "");
+ if (is_int)
+ av_bprintf(buf, "
%s: %"PRId64"", key, num);
+ else
+ av_bprintf(buf, "
%s", str);
+
+ mmc->nb_link_captions[tfc->level]++;
+ }
+}
+
+static inline void mermaid_print_str(AVTextFormatContext *tfc, const char *key, const char *value)
+{
+ mermaid_print_value(tfc, key, value, 0, 0);
+}
+
+static void mermaid_print_int(AVTextFormatContext *tfc, const char *key, int64_t value)
+{
+ mermaid_print_value(tfc, key, NULL, value, 1);
+}
+
+const AVTextFormatter avtextformatter_mermaid = {
+ .name = "mermaid",
+ .priv_size = sizeof(MermaidContext),
+ .init = mermaid_init,
+ .uninit = mermaid_uninit,
+ .print_section_header = mermaid_print_section_header,
+ .print_section_footer = mermaid_print_section_footer,
+ .print_integer = mermaid_print_int,
+ .print_string = mermaid_print_str,
+ .flags = AV_TEXTFORMAT_FLAG_IS_DIAGRAM_FORMATTER,
+ .priv_class = &mermaid_class,
+};
+
+
+const AVTextFormatter avtextformatter_mermaidhtml = {
+ .name = "mermaidhtml",
+ .priv_size = sizeof(MermaidContext),
+ .init = mermaid_init_html,
+ .uninit = mermaid_uninit,
+ .print_section_header = mermaid_print_section_header,
+ .print_section_footer = mermaid_print_section_footer,
+ .print_integer = mermaid_print_int,
+ .print_string = mermaid_print_str,
+ .flags = AV_TEXTFORMAT_FLAG_IS_DIAGRAM_FORMATTER,
+ .priv_class = &mermaid_class,
+};
diff --git a/src/fftools/textformat/tf_mermaid.h b/src/fftools/textformat/tf_mermaid.h
new file mode 100644
index 00000000000..6e8f2a9b421
--- /dev/null
+++ b/src/fftools/textformat/tf_mermaid.h
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) The FFmpeg developers
+ *
+ * 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
+ */
+
+#ifndef FFTOOLS_TEXTFORMAT_TF_MERMAID_H
+#define FFTOOLS_TEXTFORMAT_TF_MERMAID_H
+
+typedef enum {
+ AV_DIAGRAMTYPE_GRAPH,
+ AV_DIAGRAMTYPE_ENTITYRELATIONSHIP,
+} AVDiagramType;
+
+typedef struct AVDiagramConfig {
+ AVDiagramType diagram_type;
+ const char *diagram_css;
+ const char *html_template;
+} AVDiagramConfig;
+
+
+void av_diagram_init(AVTextFormatContext *tfc, AVDiagramConfig *diagram_config);
+
+void av_mermaid_set_html_template(AVTextFormatContext *tfc, const char *html_template);
+
+
+#endif /* FFTOOLS_TEXTFORMAT_TF_MERMAID_H */
diff --git a/src/fftools/textformat/tf_xml.c b/src/fftools/textformat/tf_xml.c
new file mode 100644
index 00000000000..d4329110f9a
--- /dev/null
+++ b/src/fftools/textformat/tf_xml.c
@@ -0,0 +1,212 @@
+/*
+ * Copyright (c) The FFmpeg developers
+ *
+ * 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
+#include
+
+#include "avtextformat.h"
+#include "libavutil/bprint.h"
+#include "libavutil/error.h"
+#include "libavutil/opt.h"
+#include "tf_internal.h"
+
+/* XML output */
+
+typedef struct XMLContext {
+ const AVClass *class;
+ int within_tag;
+ int indent_level;
+ int fully_qualified;
+ int xsd_strict;
+} XMLContext;
+
+#undef OFFSET
+#define OFFSET(x) offsetof(XMLContext, x)
+
+static const AVOption xml_options[] = {
+ { "fully_qualified", "specify if the output should be fully qualified", OFFSET(fully_qualified), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1 },
+ { "q", "specify if the output should be fully qualified", OFFSET(fully_qualified), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1 },
+ { "xsd_strict", "ensure that the output is XSD compliant", OFFSET(xsd_strict), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1 },
+ { "x", "ensure that the output is XSD compliant", OFFSET(xsd_strict), AV_OPT_TYPE_BOOL, { .i64 = 0 }, 0, 1 },
+ { NULL },
+};
+
+DEFINE_FORMATTER_CLASS(xml);
+
+static av_cold int xml_init(AVTextFormatContext *wctx)
+{
+ XMLContext *xml = wctx->priv;
+
+ if (xml->xsd_strict) {
+ xml->fully_qualified = 1;
+#define CHECK_COMPLIANCE(opt, opt_name) \
+ if (opt) { \
+ av_log(wctx, AV_LOG_ERROR, \
+ "XSD-compliant output selected but option '%s' was selected, XML output may be non-compliant.\n" \
+ "You need to disable such option with '-no%s'\n", opt_name, opt_name); \
+ return AVERROR(EINVAL); \
+ }
+ ////CHECK_COMPLIANCE(show_private_data, "private");
+ CHECK_COMPLIANCE(wctx->opts.show_value_unit, "unit");
+ CHECK_COMPLIANCE(wctx->opts.use_value_prefix, "prefix");
+ }
+
+ return 0;
+}
+
+#define XML_INDENT() writer_printf(wctx, "%*c", xml->indent_level * 4, ' ')
+
+static void xml_print_section_header(AVTextFormatContext *wctx, const void *data)
+{
+ XMLContext *xml = wctx->priv;
+ const AVTextFormatSection *section = tf_get_section(wctx, wctx->level);
+ const AVTextFormatSection *parent_section = tf_get_parent_section(wctx, wctx->level);
+
+ if (!section)
+ return;
+
+ if (wctx->level == 0) {
+ const char *qual = " xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
+ "xmlns:ffprobe=\"http://www.ffmpeg.org/schema/ffprobe\" "
+ "xsi:schemaLocation=\"http://www.ffmpeg.org/schema/ffprobe ffprobe.xsd\"";
+
+ writer_put_str(wctx, "\n");
+ writer_printf(wctx, "<%sffprobe%s>\n",
+ xml->fully_qualified ? "ffprobe:" : "",
+ xml->fully_qualified ? qual : "");
+ return;
+ }
+
+ if (xml->within_tag) {
+ xml->within_tag = 0;
+ writer_put_str(wctx, ">\n");
+ }
+
+ if (parent_section && (parent_section->flags & AV_TEXTFORMAT_SECTION_FLAG_IS_WRAPPER) &&
+ wctx->level && wctx->nb_item[wctx->level - 1])
+ writer_w8(wctx, '\n');
+ xml->indent_level++;
+
+ if (section->flags & (AV_TEXTFORMAT_SECTION_FLAG_IS_ARRAY | AV_TEXTFORMAT_SECTION_FLAG_HAS_VARIABLE_FIELDS)) {
+ XML_INDENT();
+ writer_printf(wctx, "<%s", section->name);
+
+ if (section->flags & AV_TEXTFORMAT_SECTION_FLAG_HAS_TYPE) {
+ AVBPrint buf;
+ av_bprint_init(&buf, 1, AV_BPRINT_SIZE_UNLIMITED);
+ av_bprint_escape(&buf, section->get_type(data), NULL,
+ AV_ESCAPE_MODE_XML, AV_ESCAPE_FLAG_XML_DOUBLE_QUOTES);
+ writer_printf(wctx, " type=\"%s\"", buf.str);
+ }
+ writer_printf(wctx, ">\n", section->name);
+ } else {
+ XML_INDENT();
+ writer_printf(wctx, "<%s ", section->name);
+ xml->within_tag = 1;
+ }
+}
+
+static void xml_print_section_footer(AVTextFormatContext *wctx)
+{
+ XMLContext *xml = wctx->priv;
+ const AVTextFormatSection *section = tf_get_section(wctx, wctx->level);
+
+ if (!section)
+ return;
+
+ if (wctx->level == 0) {
+ writer_printf(wctx, "%sffprobe>\n", xml->fully_qualified ? "ffprobe:" : "");
+ } else if (xml->within_tag) {
+ xml->within_tag = 0;
+ writer_put_str(wctx, "/>\n");
+ xml->indent_level--;
+ } else {
+ XML_INDENT();
+ writer_printf(wctx, "%s>\n", section->name);
+ xml->indent_level--;
+ }
+}
+
+static void xml_print_value(AVTextFormatContext *wctx, const char *key,
+ const char *str, int64_t num, const int is_int)
+{
+ AVBPrint buf;
+ XMLContext *xml = wctx->priv;
+ const AVTextFormatSection *section = tf_get_section(wctx, wctx->level);
+
+ if (!section)
+ return;
+
+ av_bprint_init(&buf, 1, AV_BPRINT_SIZE_UNLIMITED);
+
+ if (section->flags & AV_TEXTFORMAT_SECTION_FLAG_HAS_VARIABLE_FIELDS) {
+ xml->indent_level++;
+ XML_INDENT();
+ av_bprint_escape(&buf, key, NULL,
+ AV_ESCAPE_MODE_XML, AV_ESCAPE_FLAG_XML_DOUBLE_QUOTES);
+ writer_printf(wctx, "<%s key=\"%s\"",
+ section->element_name, buf.str);
+ av_bprint_clear(&buf);
+
+ if (is_int) {
+ writer_printf(wctx, " value=\"%"PRId64"\"/>\n", num);
+ } else {
+ av_bprint_escape(&buf, str, NULL,
+ AV_ESCAPE_MODE_XML, AV_ESCAPE_FLAG_XML_DOUBLE_QUOTES);
+ writer_printf(wctx, " value=\"%s\"/>\n", buf.str);
+ }
+ xml->indent_level--;
+ } else {
+ if (wctx->nb_item[wctx->level])
+ writer_w8(wctx, ' ');
+
+ if (is_int) {
+ writer_printf(wctx, "%s=\"%"PRId64"\"", key, num);
+ } else {
+ av_bprint_escape(&buf, str, NULL,
+ AV_ESCAPE_MODE_XML, AV_ESCAPE_FLAG_XML_DOUBLE_QUOTES);
+ writer_printf(wctx, "%s=\"%s\"", key, buf.str);
+ }
+ }
+
+ av_bprint_finalize(&buf, NULL);
+}
+
+static inline void xml_print_str(AVTextFormatContext *wctx, const char *key, const char *value)
+{
+ xml_print_value(wctx, key, value, 0, 0);
+}
+
+static void xml_print_int(AVTextFormatContext *wctx, const char *key, int64_t value)
+{
+ xml_print_value(wctx, key, NULL, value, 1);
+}
+
+const AVTextFormatter avtextformatter_xml = {
+ .name = "xml",
+ .priv_size = sizeof(XMLContext),
+ .init = xml_init,
+ .print_section_header = xml_print_section_header,
+ .print_section_footer = xml_print_section_footer,
+ .print_integer = xml_print_int,
+ .print_string = xml_print_str,
+ .flags = AV_TEXTFORMAT_FLAG_SUPPORTS_MIXED_ARRAY_CONTENT,
+ .priv_class = &xml_class,
+};
diff --git a/src/fftools/textformat/tw_avio.c b/src/fftools/textformat/tw_avio.c
new file mode 100644
index 00000000000..21d3af27df1
--- /dev/null
+++ b/src/fftools/textformat/tw_avio.c
@@ -0,0 +1,124 @@
+/*
+ * Copyright (c) The FFmpeg developers
+ *
+ * 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
+#include
+#include
+
+#include "avtextwriters.h"
+#include "libavutil/avassert.h"
+
+#include "libavutil/error.h"
+
+/* AVIO Writer */
+
+# define WRITER_NAME "aviowriter"
+
+typedef struct IOWriterContext {
+ const AVClass *class;
+ AVIOContext *avio_context;
+ int close_on_uninit;
+} IOWriterContext;
+
+static av_cold int iowriter_uninit(AVTextWriterContext *wctx)
+{
+ IOWriterContext *ctx = wctx->priv;
+ int ret = 0;
+
+ if (ctx->close_on_uninit)
+ ret = avio_closep(&ctx->avio_context);
+ return ret;
+}
+
+static void io_w8(AVTextWriterContext *wctx, int b)
+{
+ IOWriterContext *ctx = wctx->priv;
+ avio_w8(ctx->avio_context, b);
+}
+
+static void io_put_str(AVTextWriterContext *wctx, const char *str)
+{
+ IOWriterContext *ctx = wctx->priv;
+ avio_write(ctx->avio_context, (const unsigned char *)str, (int)strlen(str));
+}
+
+static void io_vprintf(AVTextWriterContext *wctx, const char *fmt, va_list vl)
+{
+ IOWriterContext *ctx = wctx->priv;
+
+ avio_vprintf(ctx->avio_context, fmt, vl);
+}
+
+
+const AVTextWriter avtextwriter_avio = {
+ .name = WRITER_NAME,
+ .priv_size = sizeof(IOWriterContext),
+ .uninit = iowriter_uninit,
+ .writer_put_str = io_put_str,
+ .writer_vprintf = io_vprintf,
+ .writer_w8 = io_w8
+};
+
+int avtextwriter_create_file(AVTextWriterContext **pwctx, const char *output_filename)
+{
+ IOWriterContext *ctx;
+ int ret;
+
+ if (!output_filename || !output_filename[0]) {
+ av_log(NULL, AV_LOG_ERROR, "The output_filename cannot be NULL or empty\n");
+ return AVERROR(EINVAL);
+ }
+
+ ret = avtextwriter_context_open(pwctx, &avtextwriter_avio);
+ if (ret < 0)
+ return ret;
+
+ ctx = (*pwctx)->priv;
+
+ if ((ret = avio_open(&ctx->avio_context, output_filename, AVIO_FLAG_WRITE)) < 0) {
+ av_log(ctx, AV_LOG_ERROR,
+ "Failed to open output '%s' with error: %s\n", output_filename, av_err2str(ret));
+ avtextwriter_context_close(pwctx);
+ return ret;
+ }
+
+ ctx->close_on_uninit = 1;
+
+ return ret;
+}
+
+
+int avtextwriter_create_avio(AVTextWriterContext **pwctx, AVIOContext *avio_ctx, int close_on_uninit)
+{
+ IOWriterContext *ctx;
+ int ret;
+
+ av_assert0(avio_ctx);
+
+ ret = avtextwriter_context_open(pwctx, &avtextwriter_avio);
+ if (ret < 0)
+ return ret;
+
+ ctx = (*pwctx)->priv;
+ ctx->avio_context = avio_ctx;
+ ctx->close_on_uninit = close_on_uninit;
+
+ return ret;
+}
diff --git a/src/fftools/textformat/tw_buffer.c b/src/fftools/textformat/tw_buffer.c
new file mode 100644
index 00000000000..f6e63445d9c
--- /dev/null
+++ b/src/fftools/textformat/tw_buffer.c
@@ -0,0 +1,89 @@
+/*
+ * Copyright (c) The FFmpeg developers
+ *
+ * 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
+#include
+
+#include "avtextwriters.h"
+#include "libavutil/opt.h"
+#include "libavutil/bprint.h"
+
+/* Buffer Writer */
+
+# define WRITER_NAME "bufferwriter"
+
+typedef struct BufferWriterContext {
+ const AVClass *class;
+ AVBPrint *buffer;
+} BufferWriterContext;
+
+static const char *bufferwriter_get_name(void *ctx)
+{
+ return WRITER_NAME;
+}
+
+static const AVClass bufferwriter_class = {
+ .class_name = WRITER_NAME,
+ .item_name = bufferwriter_get_name,
+};
+
+static void buffer_w8(AVTextWriterContext *wctx, int b)
+{
+ BufferWriterContext *ctx = wctx->priv;
+ av_bprintf(ctx->buffer, "%c", b);
+}
+
+static void buffer_put_str(AVTextWriterContext *wctx, const char *str)
+{
+ BufferWriterContext *ctx = wctx->priv;
+ av_bprintf(ctx->buffer, "%s", str);
+}
+
+static void buffer_vprintf(AVTextWriterContext *wctx, const char *fmt, va_list vl)
+{
+ BufferWriterContext *ctx = wctx->priv;
+
+ av_vbprintf(ctx->buffer, fmt, vl);
+}
+
+
+const AVTextWriter avtextwriter_buffer = {
+ .name = WRITER_NAME,
+ .priv_size = sizeof(BufferWriterContext),
+ .priv_class = &bufferwriter_class,
+ .writer_put_str = buffer_put_str,
+ .writer_vprintf = buffer_vprintf,
+ .writer_w8 = buffer_w8
+};
+
+int avtextwriter_create_buffer(AVTextWriterContext **pwctx, AVBPrint *buffer)
+{
+ BufferWriterContext *ctx;
+ int ret;
+
+ ret = avtextwriter_context_open(pwctx, &avtextwriter_buffer);
+ if (ret < 0)
+ return ret;
+
+ ctx = (*pwctx)->priv;
+ ctx->buffer = buffer;
+
+ return ret;
+}
diff --git a/src/fftools/textformat/tw_stdout.c b/src/fftools/textformat/tw_stdout.c
new file mode 100644
index 00000000000..3e2a8dd0d46
--- /dev/null
+++ b/src/fftools/textformat/tw_stdout.c
@@ -0,0 +1,78 @@
+/*
+ * Copyright (c) The FFmpeg developers
+ *
+ * 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
+#include
+#include
+
+#include "avtextwriters.h"
+#include "libavutil/opt.h"
+
+/* STDOUT Writer */
+
+# define WRITER_NAME "stdoutwriter"
+
+typedef struct StdOutWriterContext {
+ const AVClass *class;
+} StdOutWriterContext;
+
+static const char *stdoutwriter_get_name(void *ctx)
+{
+ return WRITER_NAME;
+}
+
+static const AVClass stdoutwriter_class = {
+ .class_name = WRITER_NAME,
+ .item_name = stdoutwriter_get_name,
+};
+
+static inline void stdout_w8(AVTextWriterContext *wctx, int b)
+{
+ printf("%c", b);
+}
+
+static inline void stdout_put_str(AVTextWriterContext *wctx, const char *str)
+{
+ printf("%s", str);
+}
+
+static inline void stdout_vprintf(AVTextWriterContext *wctx, const char *fmt, va_list vl)
+{
+ vprintf(fmt, vl);
+}
+
+
+static const AVTextWriter avtextwriter_stdout = {
+ .name = WRITER_NAME,
+ .priv_size = sizeof(StdOutWriterContext),
+ .priv_class = &stdoutwriter_class,
+ .writer_put_str = stdout_put_str,
+ .writer_vprintf = stdout_vprintf,
+ .writer_w8 = stdout_w8
+};
+
+int avtextwriter_create_stdout(AVTextWriterContext **pwctx)
+{
+ int ret;
+
+ ret = avtextwriter_context_open(pwctx, &avtextwriter_stdout);
+
+ return ret;
+}
diff --git a/src/fftools/thread_queue.c b/src/fftools/thread_queue.c
new file mode 100644
index 00000000000..eb33431c98b
--- /dev/null
+++ b/src/fftools/thread_queue.c
@@ -0,0 +1,268 @@
+/*
+ * 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
+#include
+
+#include "libavutil/avassert.h"
+#include "libavutil/container_fifo.h"
+#include "libavutil/error.h"
+#include "libavutil/fifo.h"
+#include "libavutil/frame.h"
+#include "libavutil/intreadwrite.h"
+#include "libavutil/mem.h"
+#include "libavutil/thread.h"
+
+#include "libavcodec/packet.h"
+
+#include "thread_queue.h"
+
+enum {
+ FINISHED_SEND = (1 << 0),
+ FINISHED_RECV = (1 << 1),
+};
+
+struct ThreadQueue {
+ int choked;
+ int *finished;
+ unsigned int nb_streams;
+
+ enum ThreadQueueType type;
+
+ AVContainerFifo *fifo;
+ AVFifo *fifo_stream_index;
+
+ pthread_mutex_t lock;
+ pthread_cond_t cond;
+};
+
+void tq_free(ThreadQueue **ptq)
+{
+ ThreadQueue *tq = *ptq;
+
+ if (!tq)
+ return;
+
+ av_container_fifo_free(&tq->fifo);
+ av_fifo_freep2(&tq->fifo_stream_index);
+
+ av_freep(&tq->finished);
+
+ pthread_cond_destroy(&tq->cond);
+ pthread_mutex_destroy(&tq->lock);
+
+ av_freep(ptq);
+}
+
+ThreadQueue *tq_alloc(unsigned int nb_streams, size_t queue_size,
+ enum ThreadQueueType type)
+{
+ ThreadQueue *tq;
+ int ret;
+
+ tq = av_mallocz(sizeof(*tq));
+ if (!tq)
+ return NULL;
+
+ ret = pthread_cond_init(&tq->cond, NULL);
+ if (ret) {
+ av_freep(&tq);
+ return NULL;
+ }
+
+ ret = pthread_mutex_init(&tq->lock, NULL);
+ if (ret) {
+ pthread_cond_destroy(&tq->cond);
+ av_freep(&tq);
+ return NULL;
+ }
+
+ tq->finished = av_calloc(nb_streams, sizeof(*tq->finished));
+ if (!tq->finished)
+ goto fail;
+ tq->nb_streams = nb_streams;
+
+ tq->type = type;
+
+ tq->fifo = (type == THREAD_QUEUE_FRAMES) ?
+ av_container_fifo_alloc_avframe(0) : av_container_fifo_alloc_avpacket(0);
+ if (!tq->fifo)
+ goto fail;
+
+ tq->fifo_stream_index = av_fifo_alloc2(queue_size, sizeof(unsigned), 0);
+ if (!tq->fifo_stream_index)
+ goto fail;
+
+ return tq;
+fail:
+ tq_free(&tq);
+ return NULL;
+}
+
+int tq_send(ThreadQueue *tq, unsigned int stream_idx, void *data)
+{
+ int *finished;
+ int ret;
+
+ av_assert0(stream_idx < tq->nb_streams);
+ finished = &tq->finished[stream_idx];
+
+ pthread_mutex_lock(&tq->lock);
+
+ if (*finished & FINISHED_SEND) {
+ ret = AVERROR(EINVAL);
+ goto finish;
+ }
+
+ while (!(*finished & FINISHED_RECV) && !av_fifo_can_write(tq->fifo_stream_index))
+ pthread_cond_wait(&tq->cond, &tq->lock);
+
+ if (*finished & FINISHED_RECV) {
+ ret = AVERROR_EOF;
+ *finished |= FINISHED_SEND;
+ } else {
+ ret = av_fifo_write(tq->fifo_stream_index, &stream_idx, 1);
+ if (ret < 0)
+ goto finish;
+
+ ret = av_container_fifo_write(tq->fifo, data, 0);
+ if (ret < 0)
+ goto finish;
+
+ pthread_cond_broadcast(&tq->cond);
+ }
+
+finish:
+ pthread_mutex_unlock(&tq->lock);
+
+ return ret;
+}
+
+static int receive_locked(ThreadQueue *tq, int *stream_idx,
+ void *data)
+{
+ unsigned int nb_finished = 0;
+
+ if (tq->choked)
+ return AVERROR(EAGAIN);
+
+ while (av_container_fifo_read(tq->fifo, data, 0) >= 0) {
+ unsigned idx;
+ int ret;
+
+ ret = av_fifo_read(tq->fifo_stream_index, &idx, 1);
+ av_assert0(ret >= 0);
+ if (tq->finished[idx] & FINISHED_RECV) {
+ (tq->type == THREAD_QUEUE_FRAMES) ?
+ av_frame_unref(data) : av_packet_unref(data);
+ continue;
+ }
+
+ *stream_idx = idx;
+ return 0;
+ }
+
+ for (unsigned int i = 0; i < tq->nb_streams; i++) {
+ if (!tq->finished[i])
+ continue;
+
+ /* return EOF to the consumer at most once for each stream */
+ if (!(tq->finished[i] & FINISHED_RECV)) {
+ tq->finished[i] |= FINISHED_RECV;
+ *stream_idx = i;
+ return AVERROR_EOF;
+ }
+
+ nb_finished++;
+ }
+
+ return nb_finished == tq->nb_streams ? AVERROR_EOF : AVERROR(EAGAIN);
+}
+
+int tq_receive(ThreadQueue *tq, int *stream_idx, void *data)
+{
+ int ret;
+
+ *stream_idx = -1;
+
+ pthread_mutex_lock(&tq->lock);
+
+ while (1) {
+ size_t can_read = av_container_fifo_can_read(tq->fifo);
+
+ ret = receive_locked(tq, stream_idx, data);
+
+ // signal other threads if the fifo state changed
+ if (can_read != av_container_fifo_can_read(tq->fifo))
+ pthread_cond_broadcast(&tq->cond);
+
+ if (ret == AVERROR(EAGAIN)) {
+ pthread_cond_wait(&tq->cond, &tq->lock);
+ continue;
+ }
+
+ break;
+ }
+
+ pthread_mutex_unlock(&tq->lock);
+
+ return ret;
+}
+
+void tq_send_finish(ThreadQueue *tq, unsigned int stream_idx)
+{
+ av_assert0(stream_idx < tq->nb_streams);
+
+ pthread_mutex_lock(&tq->lock);
+
+ /* mark the stream as send-finished;
+ * next time the consumer thread tries to read this stream it will get
+ * an EOF and recv-finished flag will be set */
+ tq->finished[stream_idx] |= FINISHED_SEND;
+ tq->choked = 0;
+ pthread_cond_broadcast(&tq->cond);
+
+ pthread_mutex_unlock(&tq->lock);
+}
+
+void tq_receive_finish(ThreadQueue *tq, unsigned int stream_idx)
+{
+ av_assert0(stream_idx < tq->nb_streams);
+
+ pthread_mutex_lock(&tq->lock);
+
+ /* mark the stream as recv-finished;
+ * next time the producer thread tries to send for this stream, it will
+ * get an EOF and send-finished flag will be set */
+ tq->finished[stream_idx] |= FINISHED_RECV;
+ pthread_cond_broadcast(&tq->cond);
+
+ pthread_mutex_unlock(&tq->lock);
+}
+
+void tq_choke(ThreadQueue *tq, int choked)
+{
+ pthread_mutex_lock(&tq->lock);
+
+ int prev_choked = tq->choked;
+ tq->choked = choked;
+ if (choked != prev_choked)
+ pthread_cond_broadcast(&tq->cond);
+
+ pthread_mutex_unlock(&tq->lock);
+}
diff --git a/src/fftools/thread_queue.h b/src/fftools/thread_queue.h
new file mode 100644
index 00000000000..ad7669f1318
--- /dev/null
+++ b/src/fftools/thread_queue.h
@@ -0,0 +1,90 @@
+/*
+ * 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
+ */
+
+#ifndef FFTOOLS_THREAD_QUEUE_H
+#define FFTOOLS_THREAD_QUEUE_H
+
+#include
+
+enum ThreadQueueType {
+ THREAD_QUEUE_FRAMES,
+ THREAD_QUEUE_PACKETS,
+};
+
+typedef struct ThreadQueue ThreadQueue;
+
+/**
+ * Allocate a queue for sending data between threads.
+ *
+ * @param nb_streams number of streams for which a distinct EOF state is
+ * maintained
+ * @param queue_size number of items that can be stored in the queue without
+ * blocking
+ */
+ThreadQueue *tq_alloc(unsigned int nb_streams, size_t queue_size,
+ enum ThreadQueueType type);
+void tq_free(ThreadQueue **tq);
+
+/**
+ * Send an item for the given stream to the queue.
+ *
+ * @param data the item to send, its contents will be moved using the callback
+ * provided to tq_alloc(); on failure the item will be left
+ * untouched
+ * @return
+ * - 0 the item was successfully sent
+ * - AVERROR(ENOMEM) could not allocate an item for writing to the FIFO
+ * - AVERROR(EINVAL) the sending side has previously been marked as finished
+ * - AVERROR_EOF the receiving side has marked the given stream as finished
+ */
+int tq_send(ThreadQueue *tq, unsigned int stream_idx, void *data);
+/**
+ * Mark the given stream finished from the sending side.
+ */
+void tq_send_finish(ThreadQueue *tq, unsigned int stream_idx);
+
+/**
+ * Prevent further reads from the thread queue until it is unchoked. Threads
+ * attempting to read from the queue will block, similar to when the queue is
+ * empty.
+ *
+ * @param choked 1 to choke, 0 to unchoke
+ */
+void tq_choke(ThreadQueue *tq, int choked);
+
+/**
+ * Read the next item from the queue.
+ *
+ * @param stream_idx the index of the stream that was processed or -1 will be
+ * written here
+ * @param data the data item will be written here on success using the
+ * callback provided to tq_alloc()
+ * @return
+ * - 0 a data item was successfully read; *stream_idx contains a non-negative
+ * stream index
+ * - AVERROR_EOF When *stream_idx is non-negative, this signals that the sending
+ * side has marked the given stream as finished. This will happen at most once
+ * for each stream. When *stream_idx is -1, all streams are done.
+ */
+int tq_receive(ThreadQueue *tq, int *stream_idx, void *data);
+/**
+ * Mark the given stream finished from the receiving side.
+ */
+void tq_receive_finish(ThreadQueue *tq, unsigned int stream_idx);
+
+#endif // FFTOOLS_THREAD_QUEUE_H
diff --git a/tests/ffmpeg-cliptool-copy.test.html b/tests/ffmpeg-cliptool-copy.test.html
new file mode 100644
index 00000000000..c58b4e5d9c0
--- /dev/null
+++ b/tests/ffmpeg-cliptool-copy.test.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+ Cliptool Vetting (ST copy-only, non-isolated)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ffmpeg-cliptool-mt.test.html b/tests/ffmpeg-cliptool-mt.test.html
new file mode 100644
index 00000000000..1b70386e9f7
--- /dev/null
+++ b/tests/ffmpeg-cliptool-mt.test.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Cliptool Vetting (MT, isolated)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ffmpeg-cliptool-st.test.html b/tests/ffmpeg-cliptool-st.test.html
new file mode 100644
index 00000000000..4f40762db38
--- /dev/null
+++ b/tests/ffmpeg-cliptool-st.test.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+ Cliptool Vetting (ST full, non-isolated)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ffmpeg-cliptool.test.js b/tests/ffmpeg-cliptool.test.js
new file mode 100644
index 00000000000..1e54166a743
--- /dev/null
+++ b/tests/ffmpeg-cliptool.test.js
@@ -0,0 +1,221 @@
+const { FFmpeg } = window.FFmpegWASM;
+
+const genName = (name) => `[cliptool][${FFMPEG_TYPE}] ${name}`;
+
+// Vetting suite for the clipping-tool-ui pipeline: replicates its ONLY two
+// ffmpeg invocations verbatim (src/app/core/media/clip-export/
+// ffmpeg-clip-engine.ts — runClipExport and runClipStitch). The consumer has
+// committed to not expanding beyond these two commands, so this suite covers
+// both production command shapes — against SYNTHESIZED inputs. It does not
+// prove behavior on real packager-produced segments; that vetting still needs
+// a checked-in fixture from an actual stream.
+//
+// Two-core design: SYNTH_CORE_URL manufactures the inputs — H.264+AAC MPEG-TS
+// segments and an .m4a audio track — because the copy-only core deliberately
+// has no encoders or mpegts muxer. CORE_URL is the core under test; it only
+// ever sees the two production commands. Run the suite via both the ST page
+// and the MT page (ffmpeg-cliptool-mt.test.html) for a direct cross-engine
+// A/B on identical commands.
+let synth;
+let ffmpeg;
+const inputs = {};
+
+before(async function () {
+ this.timeout(120000);
+ // Phase 1: synthesize inputs on the full core.
+ synth = new FFmpeg();
+ // The synth core is the same engine family as the page (full ST on the ST
+ // pages, MT on the MT page), so it shares the DUT's threading mode.
+ await synth.load({ coreURL: SYNTH_CORE_URL, thread: FFMPEG_TYPE === "mt" });
+ await synth.writeFile("video.mp4", b64ToUint8Array(VIDEO_1S_MP4));
+ // testdata/ is a git submodule: an unpopulated checkout serves a 404 here,
+ // and writing that body to MEMFS surfaces later as a cryptic ffmpeg ret=1.
+ const wav = await fetch("../testdata/audio-1s.wav");
+ if (!wav.ok) {
+ throw new Error(
+ `fixture fetch failed (${wav.status}): is the testdata submodule checked out?`
+ );
+ }
+ await synth.writeFile("audio.wav", new Uint8Array(await wav.arrayBuffer()));
+
+ // H.264+AAC TS segments — the shape the clip-export path consumes. Re-encode
+ // with a short GOP (keyframe every 15 frames): the repo fixture has a single
+ // keyframe at frame 0, and an input-side -ss past it under -c copy drops
+ // every remaining (non-key) video packet, producing an audio-only clip.
+ // Real segments carry regular keyframes; the synth must too.
+ // -threads 1 on the synth encodes: the MT core intermittently deadlocks on
+ // pthread churn mid-run (the same class clipping-tool-ui works around with
+ // its standing -threads 1 rule); a no-op on the ST cores.
+ const segArgs = (out) => [
+ "-threads", "1",
+ "-i", "video.mp4", "-i", "audio.wav",
+ "-c:v", "libx264", "-preset", "ultrafast",
+ "-x264-params", "keyint=15:min-keyint=15:scenecut=0",
+ "-c:a", "aac", "-shortest", "-threads", "1", "-f", "mpegts", out,
+ ];
+ let ret = await synth.exec(segArgs("seg-a.ts"));
+ if (ret !== 0) throw new Error(`TS segment synthesis failed (ret=${ret})`);
+ ret = await synth.exec(segArgs("seg-b.ts"));
+ if (ret !== 0) throw new Error(`TS segment synthesis failed (ret=${ret})`);
+ // The optional whole-frame-trimmed audio track (-f mov input in production).
+ ret = await synth.exec([
+ "-threads", "1", "-i", "audio.wav", "-c:a", "aac", "-threads", "1", "audio.m4a",
+ ]);
+ if (ret !== 0) throw new Error(`m4a synthesis failed (ret=${ret})`);
+
+ // Phase 2: the core under test. When synth and DUT are the same core (the
+ // ST-full and MT pages), reuse the instance — terminate-and-reload within
+ // one page trips the emsdk 6.0.2 pthread-pool leak on the MT core (see the
+ // single-instance note in ffmpeg.test.js), and the synthesized files are
+ // already in this instance's MEMFS.
+ if (SYNTH_CORE_URL === CORE_URL) {
+ ffmpeg = synth;
+ synth = null;
+ return;
+ }
+ inputs.segA = await synth.readFile("seg-a.ts");
+ inputs.segB = await synth.readFile("seg-b.ts");
+ inputs.m4a = await synth.readFile("audio.m4a");
+ synth.terminate();
+ synth = null;
+
+ ffmpeg = new FFmpeg();
+ await ffmpeg.load({
+ coreURL: CORE_URL,
+ thread: FFMPEG_TYPE === "mt",
+ });
+ await ffmpeg.writeFile("seg-a.ts", inputs.segA);
+ await ffmpeg.writeFile("seg-b.ts", inputs.segB);
+ await ffmpeg.writeFile("audio.m4a", inputs.m4a);
+});
+
+after(() => {
+ if (synth) synth.terminate();
+ if (ffmpeg) ffmpeg.terminate();
+});
+
+// Container inventory via a bare `-i` (exits nonzero by design; only the log
+// matters). Size-only assertions let an audio-only mp4 masquerade as a clip,
+// and stream-only assertions let a wrong-length or offset clip pass — the
+// Duration/start line is where 5.1 and 8.x timestamp handling would diverge.
+const probe = async (name) => {
+ const logs = [];
+ const listener = ({ message }) => logs.push(message);
+ ffmpeg.on("log", listener);
+ await ffmpeg.exec(["-i", name]);
+ ffmpeg.off("log", listener);
+ const joined = logs.join("\n");
+ const m = joined.match(/Duration: (\d+):(\d+):([\d.]+), start: ([\d.-]+)/);
+ return {
+ streams: logs.filter((l) => /Stream #/.test(l)).join("\n"),
+ duration: m ? +m[1] * 3600 + +m[2] * 60 + parseFloat(m[3]) : NaN,
+ start: m ? parseFloat(m[4]) : NaN,
+ };
+};
+
+// GPL-free gate for the copy core (set EXPECT_GPL_FREE in the page): no
+// --enable-gpl in the built-in configuration means configure admitted no
+// GPL-gated component (it hard-errors otherwise), so the artifact is LGPL.
+if (window.EXPECT_GPL_FREE) {
+ describe(genName("licensing"), function () {
+ it("core is built without --enable-gpl (LGPL)", async () => {
+ const logs = [];
+ const listener = ({ message }) => logs.push(message);
+ ffmpeg.on("log", listener);
+ await ffmpeg.exec(["-version"]);
+ ffmpeg.off("log", listener);
+ const banner = logs.join("\n");
+ expect(banner).to.match(/configuration:/);
+ expect(banner).to.not.match(/--enable-gpl/);
+ expect(banner).to.not.match(/--enable-nonfree/);
+ });
+ });
+}
+
+describe(genName("clip export (runClipExport, verbatim)"), function () {
+ it("stream-copies a window out of concatenated TS segments + m4a audio", async () => {
+ await ffmpeg.writeFile("concat.txt", "file 'seg-a.ts'\nfile 'seg-b.ts'\n");
+ const ret = await ffmpeg.exec([
+ "-threads", "1",
+ "-ss", "0.200",
+ "-f", "concat", "-safe", "0", "-i", "concat.txt",
+ "-f", "mov", "-i", "audio.m4a",
+ "-t", "1.000",
+ "-map", "0:v:0", "-map", "1:a:0",
+ "-c", "copy",
+ "-avoid_negative_ts", "make_zero",
+ "-movflags", "+faststart",
+ "output.mp4",
+ ]);
+ expect(ret).to.equal(0);
+ const out = await ffmpeg.readFile("output.mp4");
+ expect(out.length).to.be.greaterThan(0);
+ const p = await probe("output.mp4");
+ expect(p.streams).to.match(/Video: h264/);
+ expect(p.streams).to.match(/Audio: aac/);
+ // -t 1.000 with input -ss keyframe snap-back: allow up to a GOP of video
+ // pre-roll (15 frames ≈ 0.43s at the synth cadence) but never a short clip.
+ expect(p.duration).to.be.within(0.9, 1.6);
+ // -avoid_negative_ts make_zero must rebase the copied TS timestamps.
+ expect(p.start).to.be.closeTo(0, 0.05);
+ });
+
+ it("stream-copies video-only (no audioUrl path: no -map args)", async () => {
+ const ret = await ffmpeg.exec([
+ "-threads", "1",
+ "-ss", "0.200",
+ "-f", "concat", "-safe", "0", "-i", "concat.txt",
+ "-t", "1.000",
+ "-c", "copy",
+ "-avoid_negative_ts", "make_zero",
+ "-movflags", "+faststart",
+ "output-noaudio.mp4",
+ ]);
+ expect(ret).to.equal(0);
+ const out = await ffmpeg.readFile("output-noaudio.mp4");
+ expect(out.length).to.be.greaterThan(0);
+ expect((await probe("output-noaudio.mp4")).streams).to.match(/Video: h264/);
+ });
+});
+
+describe(genName("stitch (runClipStitch, verbatim)"), function () {
+ it("stitches rendered members via ffconcat with ignore_editlist", async () => {
+ const member = await ffmpeg.readFile("output.mp4");
+ // writeFile transfers the underlying ArrayBuffer to the worker; hand each
+ // write its own copy or the second one posts a detached buffer.
+ await ffmpeg.writeFile("member-0.mp4", new Uint8Array(member));
+ await ffmpeg.writeFile("member-1.mp4", new Uint8Array(member));
+ const list = `ffconcat version 1.0\n${["member-0.mp4", "member-1.mp4"]
+ .map((n) => `file '${n}'\noption ignore_editlist 1`)
+ .join("\n")}\n`;
+ await ffmpeg.writeFile("stitch.txt", list);
+ const logs = [];
+ const listener = ({ message }) => logs.push(message);
+ ffmpeg.on("log", listener);
+ const ret = await ffmpeg.exec([
+ "-y",
+ "-threads", "1",
+ "-f", "concat", "-safe", "0", "-i", "stitch.txt",
+ "-map", "0:v:0",
+ "-map", "0:a:0?",
+ "-c", "copy",
+ "-avoid_negative_ts", "make_zero",
+ "-fflags", "+bitexact",
+ "-movflags", "+faststart+negative_cts_offsets",
+ "stitched.mp4",
+ ]);
+ ffmpeg.off("log", listener);
+ if (ret !== 0) console.log(`STITCH LOG TAIL:\n${logs.slice(-12).join("\n")}`);
+ expect(ret).to.equal(0);
+ const out = await ffmpeg.readFile("stitched.mp4");
+ const single = await ffmpeg.readFile("member-0.mp4");
+ expect(out.length).to.be.greaterThan(single.length);
+ const stitched = await probe("stitched.mp4");
+ expect(stitched.streams).to.match(/Video: h264/);
+ expect(stitched.streams).to.match(/Audio: aac/);
+ // A byte-size comparison passes on a stitch that dropped part of the
+ // second member; the duration ratio is the real seam assertion.
+ const memberProbe = await probe("member-0.mp4");
+ expect(stitched.duration).to.be.closeTo(2 * memberProbe.duration, 0.2);
+ });
+});
diff --git a/tests/ffmpeg-multiinput-st.test.html b/tests/ffmpeg-multiinput-st.test.html
new file mode 100644
index 00000000000..3b60db116b1
--- /dev/null
+++ b/tests/ffmpeg-multiinput-st.test.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ FFmpeg Multi-Input Probe (ST, non-isolated)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ffmpeg-multiinput.test.js b/tests/ffmpeg-multiinput.test.js
new file mode 100644
index 00000000000..8c4188a71a9
--- /dev/null
+++ b/tests/ffmpeg-multiinput.test.js
@@ -0,0 +1,70 @@
+const { FFmpeg } = window.FFmpegWASM;
+
+const genName = (name) => `[multi-input][${FFMPEG_TYPE}] ${name}`;
+
+// Multi-input filtergraph probe. On the 7.x/8.x MT cores these deadlock in
+// wasm (FORK.md pins this on the thread-per-stage scheduler). The 5.1 ST core
+// is pre-scheduler (classic sequential transcode_step loop), so this suite is
+// the experiment that decides whether the limitation is scheduler-specific or
+// a wasm limitation independent of the frontend: passing here means the
+// FORK.md root cause holds and client-side watermark/transitions are back on
+// the table for the ST core; hanging here (mocha timeout) means the stated
+// root cause is wrong and needs re-investigation either way.
+let ffmpeg;
+
+before(async () => {
+ ffmpeg = new FFmpeg();
+ await ffmpeg.load({
+ coreURL: CORE_URL,
+ thread: FFMPEG_TYPE === "mt",
+ });
+ await ffmpeg.writeFile("video.mp4", b64ToUint8Array(VIDEO_1S_MP4));
+ // Synthesize the watermark in-core: first frame of the fixture as PNG.
+ const ret = await ffmpeg.exec([
+ "-i", "video.mp4", "-frames:v", "1", "-vf", "scale=64:-1", "wm.png",
+ ]);
+ if (ret !== 0) throw new Error(`fixture PNG synthesis failed (ret=${ret})`);
+});
+
+after(() => {
+ if (ffmpeg) ffmpeg.terminate();
+});
+
+describe(genName("overlay (video + PNG watermark)"), function () {
+ it("should overlay a PNG onto video", async () => {
+ const ret = await ffmpeg.exec([
+ "-i", "video.mp4", "-i", "wm.png",
+ "-filter_complex", "overlay=8:8",
+ "overlaid.mp4",
+ ]);
+ expect(ret).to.equal(0);
+ const out = await ffmpeg.readFile("overlaid.mp4");
+ expect(out.length).to.be.greaterThan(0);
+ });
+});
+
+describe(genName("xfade (two video inputs)"), function () {
+ it("should cross-fade two clips", async () => {
+ const ret = await ffmpeg.exec([
+ "-i", "video.mp4", "-i", "video.mp4",
+ "-filter_complex", "[0:v][1:v]xfade=transition=fade:duration=0.3:offset=0.5",
+ "xfaded.mp4",
+ ]);
+ expect(ret).to.equal(0);
+ const out = await ffmpeg.readFile("xfaded.mp4");
+ expect(out.length).to.be.greaterThan(0);
+ });
+});
+
+describe(genName("concat filter (two video inputs)"), function () {
+ it("should concat two clips via the concat filter", async () => {
+ const ret = await ffmpeg.exec([
+ "-i", "video.mp4", "-i", "video.mp4",
+ "-filter_complex", "[0:v][1:v]concat=n=2:v=1:a=0",
+ "concat-filter.mp4",
+ ]);
+ expect(ret).to.equal(0);
+ const out = await ffmpeg.readFile("concat-filter.mp4");
+ expect(out.length).to.be.greaterThan(0);
+ });
+});
diff --git a/tests/ffmpeg-perf-mt.test.html b/tests/ffmpeg-perf-mt.test.html
new file mode 100644
index 00000000000..09ed7cc9fe4
--- /dev/null
+++ b/tests/ffmpeg-perf-mt.test.html
@@ -0,0 +1,38 @@
+
+
+
+
+
+ FFmpeg Perf Probe (MT, isolated)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ffmpeg-perf-st.test.html b/tests/ffmpeg-perf-st.test.html
new file mode 100644
index 00000000000..715bae67691
--- /dev/null
+++ b/tests/ffmpeg-perf-st.test.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+ FFmpeg Perf Probe (ST, non-isolated)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ffmpeg-perf.test.js b/tests/ffmpeg-perf.test.js
new file mode 100644
index 00000000000..4f03616af98
--- /dev/null
+++ b/tests/ffmpeg-perf.test.js
@@ -0,0 +1,56 @@
+const { FFmpeg } = window.FFmpegWASM;
+
+const genName = (name) => `[perf][${FFMPEG_TYPE}] ${name}`;
+
+// Wall-clock probe for the engine decision: 1080p60 re-encode on the ST 5.1.10
+// core vs the MT 8.1.2 core (default threads and -threads 1). Timings go to
+// the console as "PERF