编写一个GStreamer插件

        前面章节对GStreamer做了概述,不过我们最终用到主要是插件,下面我们对插件做一个简单介绍,大部分内容都是copy的,并非原创,主要用于学习记录,英文好的可以看官方文档,我和官方校对过,翻译的大体上能过的去

1. 前言

1.1 什么是GStreamer?

        GStreamer是一个用于创建流媒体应用程序的框架。基本设计来自俄勒冈研究生院的视频管道,以及DirectShow的一些想法。GStreamer的开发框架使得编写任何类型的流媒体应用程序成为可能。GStreamer框架旨在使编写处理音频或视频或两者的应用程序变得容易。它不局限于音频和视频,可以处理任何类型的数据流。管道设计的开销比应用的过滤器所产生的开销要小。这使得GStreamer成为设计对延迟或性能有很高要求的高端音频应用程序的良好框架。

        GStreamer最明显的用途之一就是用来构建媒体播放器。GStreamer已经包含用于构建媒体播放器的组件,该播放器可以支持多种格式,包括MP3、Ogg/Vorbis、MPEG-1/2、AVI、Quicktime、mod等等。然而,GStreamer不仅仅是另一个媒体播放器。它的主要优点是可插入组件可以混合并匹配到任意管道中,这样就可以编写一个完整的视频或音频编辑应用程序。

        该框架基于插件,插件将提供各种编解码器和其他功能。这些插件可以链接并排列在管道中。这个管道定义了数据流。

        GStreamer的核心功能是为插件、数据流、同步和媒体类型处理/协商提供一个框架。它还提供了一个API来使用各种插件编写应用程序。

1.2 谁应该阅读本指南?

        本指南介绍如何为GStreamer编写新模块。本指南与以下几个人群相关:

  • 任何想在GStreamer中添加对处理数据的新方法的支持的人。例如,此组中的某个人可能希望创建新的数据格式转换器、新的可视化工具或新的解码器或编码器。
  • 任何想要添加对新输入和输出设备的支持的人。例如,这一组中的人可能希望增加向新的视频输出系统写入数据或从数码相机或专用麦克风读取数据的能力。
  • 任何想以任何方式扩展GStreamer的人。在理解插件系统对其余代码的约束之前,您需要了解插件系统是如何工作的。另外,读完本文后,您可能会惊讶于插件可以完成多少工作。

        如果您只想使用GStreamer的现有功能,或者只想使用使用GStreamer的应用程序,则本指南与您无关。如果您只对使用现有的插件来编写新的应用程序感兴趣,而且已经有很多插件了,那么您可能需要查看GStreamer应用程序开发手册。如果您只是想获得有关GStreamer应用程序的帮助,那么您应该查看该特定应用程序的用户手册。

1.3 初步阅读

        本指南假设您对GStreamer的基本工作原理比较熟悉。对于GStreamer中编程概念的简单介绍,您可能希望先阅读GStreamer应用程序开发手册。还可以查看GStreamer网站上提供的其他文档。

        为了理解本手册,您需要对C语言有基本的了解。由于GStreamer遵循GObject编程模型,因此本指南还假设您了解GObject编程的基础知识。您可能还想看一下ericharlow的《使用GTK+和GDK开发Linux应用程序》一书。

1.4 本指南的结构

        为了帮助您浏览本指南,它分为几个大的部分。每一部分都涉及到一个关于GStreamer插件开发的特别广泛的主题。本指南各部分按以下顺序排列:

  • 构建一个插件-介绍一个插件的结构,使用一个示例音频过滤器进行说明。

       这一部分涵盖了构建插件通常需要执行的所有基本步骤,例如向GStreamer注册元素,并设置基本步骤,以便它可以从相邻元素接收数据并向其发送数据。讨论首先给出生成基本结构和在构建样板文件时注册元素的示例。然后,您将学习如何编写代码,以获得一个基本的过滤器插件在指定垫,链函数和什么是状态。之后,我们将展示GObject的一些概念,这些概念涉及如何为应用程序配置元素,以及如何在添加属性和信号时进行应用程序元素交互。接下来,您将学习构建一个快速测试应用程序,以测试您在构建测试应用程序时所学的所有内容。我们在这里只谈基本问题。对于全面的应用程序开发,您应该查看应用程序开发手册。

  • 高级过滤器概念-有关GStreamer插件开发高级功能的信息。

        在学习了基本步骤之后,您应该能够创建一个功能强大的音频或视频过滤器插件,其中包含一些不错的特性。然而,GStreamer为插件编写器提供了更多的功能。本指南的这一部分包括有关更高级主题的章节,如调度、GStreamer中的媒体类型定义、时钟、接口和标记。由于这些特性是特定于目的的,因此您可以按任何顺序阅读它们,其中大多数不需要其他部分的知识。
        第一章,命名为不同的调度模式,将解释一些基本的元素调度。这不是很深入,但主要是介绍为什么其他事情会像它们那样工作。如果您对GStreamer内部结构感兴趣,请阅读本章。下一步,我们将应用这些知识并讨论另一种类型的数据传输,而不是您在链函数中所学到的:不同的调度模式。基于循环的元素将使您能够更好地控制输入速率。这在编写muxer或demuxer时非常有用。
        接下来,我们将在媒体类型和属性中讨论GStreamer中的媒体标识。您将学习如何定义新的媒体类型,并了解GStreamer中定义的标准媒体类型列表。
        第二章中,您将学习请求的概念,有时是pad,它们是动态创建的pad,要么是因为应用程序请求它(请求),要么是因为媒体流需要它(有时)。这将是在要求,有时垫。
        第三章“时钟”将解释GStreamer中时钟的概念。当您想知道元素应该如何实现音频/视频同步时,需要这些信息。
接下来的几章将讨论进行应用程序元素交互的高级方法。之前,我们在GObject上学习了添加属性和信号的方法。我们将讨论动态参数,它是一种在支持动态参数时,预先定义元素随时间变化的行为的方法。接下来,您将在interfaces中了解接口。接口是基于GObject的GInterface的特定于目标的应用程序元素交互方式。最后,您将了解如何在标记(metadata和Streaminfo)中的GStreamer中处理元数据。
        最后一章“事件:寻找、导航等”将讨论GStreamer中的事件概念。事件是执行应用程序元素交互的另一种方式。例如,他们负责寻找。它们也是元素相互作用的另一种方式,例如让彼此知道媒体流的不连续性、在管道中转发标记等等。

  • 创建特殊元素类型-编写其他插件类型的说明。

        因为指南的前两部分以音频过滤器为例,所以介绍的概念适用于过滤器插件。但许多概念同样适用于其他插件类型,包括源、接收器和自动插件。指南的这一部分介绍了在处理这些更专门的插件类型时出现的问题。本章首先特别关注可以使用基类(预先生成的基类)编写的元素,然后还将在编写解复用器或解析器、编写N-to-1元素或复用器以及编写管理器时讨论编写特殊类型的元素。

  • 附录-插件开发人员的进一步信息。

      附录中包含了一些顽固地拒绝与指南的其他部分完全吻合的信息。本节大部分内容尚未完成。
本指南介绍部分的其余部分简要概述了GStreamer插件开发中涉及的基本概念。涵盖的主题包括元素和插件、pad、GstMiniObject、缓冲区和事件以及媒体类型和属性。如果您已经熟悉这些信息,您可以使用这个简短的概述来刷新您的内存,或者您可以跳到构建一个插件。

正如你所看到的,有很多东西要学,所以让我们开始吧!

  • 通过从GstBin扩展来创建复合元素和复杂元素。这将允许您创建嵌入了其他插件的插件。
  • 向注册表添加新的媒体类型以及typedetect函数。这将允许你的插件操作一个全新的媒体类型。

2. 基础

        本章介绍了GStreamer的基本概念。理解这些概念将帮助您探索扩展GStreamer所涉及的问题。其中许多概念在GStreamer应用程序开发手册中有更详细的解释;这里介绍的基本概念主要用于刷新你的记忆。

2.1 元素和插件

        元素是GStreamer的核心。在插件开发的上下文中,元素是从GstElement类派生的对象。元素在与其他元素链接时提供某种功能:例如,源元素向流提供数据,而过滤器元素作用于流中的数据。没有元素,GStreamer只是一堆没有任何链接的概念性管件。GStreamer附带了大量元素,但也可以编写额外的元素。

        然而,仅仅编写一个新元素是不够的:您需要将您的元素封装在一个插件中,以使GStreamer能够使用它。插件本质上是一个可加载的代码块,通常称为共享对象文件或动态链接库。一个插件可以包含多个元素的实现,也可以只包含一个元素。为了简单起见,本指南主要关注包含一个元素的插件。

        过滤器是处理数据流的一种重要元素。数据的生产者和消费者分别称为source元素和sink元素。Bin元素包含其他元素。一种类型的bin负责同步它们所包含的元素,以便数据顺利流动。另一种类型的bin称为autoplugger elements,它会自动将其他元素添加到bin中并将它们链接在一起,以便它们充当两种任意流类型之间的过滤器。

        插件机制在GStreamer中无处不在,即使只使用标准包。一些非常基本的函数驻留在核心库中,其他所有函数都在插件中实现。插件注册表用于将插件的详细信息存储在二进制注册表文件中。这样,使用GStreamer的程序就不必加载所有插件来确定需要哪些插件。插件只有在请求它们提供的元素时才被加载。

有关GstElement和GstPlugin的当前实现细节,请参阅GStreamer库参考。

2.2 Pads

Pads用于协商GStreamer中元素之间的链接和数据流。pad可以被看作是元素上的一个“地方”或“端口”,在这里可以与其他元素建立链接,并且数据可以通过它流入或流出这些元素。pad具有特定的数据处理能力:pad可以限制流经它的数据类型。当两个焊盘允许的数据类型兼容时,才允许在两个焊盘之间进行链接。

打个比方可能会有帮助。Pad类似于物理设备上的插头或插孔。例如,考虑一个家庭影院系统,它由一个放大器、一个DVD播放器和一个(无声的)视频投影仪组成。允许将DVD播放机连接到放大器,因为两个设备都有音频插孔;允许将投影仪连接到DVD播放机,因为两个设备都有兼容的视频插孔。投影仪和放大器之间可能没有连接,因为投影仪和放大器有不同类型的插孔。GStreamer中的垫子与家庭影院系统中的插孔具有相同的用途。

在大多数情况下,GStreamer中的所有数据都单向流过元素之间的链接。数据通过一个或多个源Pad从一个元素流出,元素通过一个或多个接收器Pad接受传入的数据。source和sink元素分别只有source和sink Pad。
有关GstPad的当前实现细节,请参阅GStreamer库参考。

2.3 GstMiniObject、缓冲区和事件

GStreamer中的所有数据流都被切碎成块,从一个元素上的源pad传递到另一个元素上的sink pad。GstMiniObject是用来保存这些数据块的结构。

GstMiniObject包含以下重要类型:

  • 一个确切的类型,指示此GstMiniObject是什么类型的数据(事件、缓冲区等)。
  • 一种引用计数,表示当前保存对微型对象引用的元素数。当引用计数降为零时,将释放miniobject,并在某种意义上释放其内存(有关详细信息,请参阅下文)。

对于数据传输,定义了两种类型的GstMiniObject:事件(控件)和缓冲区(内容)。

缓冲区可以包含两个连接的焊盘知道如何处理的任何类型的数据。通常,缓冲区包含从一个元素流到另一个元素的某种音频或视频数据块。

缓冲区还包含描述缓冲区内容的元数据。一些重要的元数据类型包括:

  • 指向一个或多个GstMemory对象的指针。GstMemory对象是封装内存区域的refcounted对象。
  • 表示缓冲区中内容的优选显示时间戳的时间戳。

事件包含两个连接Pad之间流的状态信息。只有当元素显式支持事件时才会发送事件,否则核心将(尝试)自动处理事件。例如,事件用于指示媒体类型、媒体流的结束或应刷新缓存。

事件可能包含以下几项:

  • 指示所包含事件类型的子类型。
  • 事件的其他内容取决于特定的事件类型。

事件将在事件中广泛讨论:搜索、导航等。在此之前,将使用的唯一事件是EOS事件,它用于指示流的结束(通常是文件的结束)。

有关GstMiniObject、GstBuffer和GstEvent的当前实现细节,请参阅GStreamer库参考。

2.3.1 缓冲区分配

缓冲区能够存储几种不同类型的内存块。最通用的缓冲区类型包含malloc()分配的内存。这样的缓冲区虽然方便,但并不总是很快,因为数据通常需要专门复制到缓冲区中。

许多专用元素创建指向特殊内存的缓冲区。例如,filesrc元素通常将文件映射到应用程序的地址空间(使用mmap()),并创建指向该地址范围的缓冲区。filesrc创建的这些缓冲区的行为与普通缓冲区完全相同,只是它们是只读的。缓冲区释放代码自动确定释放底层内存的正确方法。接收这类缓冲区的下游元素不需要做任何特殊的处理或取消引用。

元素获取专用缓冲区的另一种方法是通过GstBufferPool或GstAllocator从下游对等方请求它们。元素可以从下游对等元素请求GstBufferPool或GstAllocator。如果下游能够提供这些对象,那么上游可以使用它们来分配缓冲区。有关内存分配的更多信息。

许多接收器单元都有将数据复制到硬件或直接访问硬件的加速方法。这些元素通常能够为它们的上游同级创建GstBufferPool或GstAllocator。一个这样的例子是ximagesink。它创建包含XImages的缓冲区。因此,当上游对等机将数据复制到缓冲区中时,它直接复制到XImage中,使得ximagesink能够将图像直接绘制到屏幕上,而不必首先将数据复制到XImage中。

过滤器元素通常有机会在缓冲区上工作,或者在从源缓冲区复制到目标缓冲区时工作。这两种算法的实现都是最佳的,因为GStreamer框架可以根据需要选择最快的算法。当然,这只对严格的过滤器有意义——在源和接收器Pad上具有完全相同格式的元素。

2.4 媒体类型和属性

GStreamer使用类型系统来确保元素之间传递的数据采用可识别的格式。类型系统对于确保在元素之间链接焊盘时,完全指定格式所需的参数正确匹配也很重要。元素之间的每个链接都有一个指定的类型和一组可选的属性。请参阅caps协商中有关caps协商的更多信息。

2.4.1 基本类型

GStreamer已经支持许多基本的媒体类型。下表列出了GStreamer中用于缓冲区的一些基本类型。该表包含名称(“媒体类型”)和类型描述、与类型关联的属性以及每个属性的含义。支持的类型的完整列表包含在已定义类型的列表中。

示例类型表

Table of Example Types
Media TypeDescriptionPropertyProperty TypeProperty ValuesProperty Description
audio/*All audio typesrateintegergreater than 0The sample rate of the data, in samples (per channel) per second.
channelsintegergreater than 0The number of channels of audio data.
audio/x-rawUnstructured and uncompressed raw integer audio data.formatstringS8 U8 S16LE S16BE U16LE U16BE S24_32LE S24_32BE U24_32LE U24_32BE S32LE S32BE U32LE U32BE S24LE S24BE U24LE U24BE S20LE S20BE U20LE U20BE S18LE S18BE U18LE U18BE F32LE F32BE F64LE F64BEThe format of the sample data.
audio/mpegAudio data compressed using the MPEG audio encoding scheme.mpegversioninteger1, 2 or 4The MPEG-version used for encoding the data. The value 1 refers to MPEG-1, -2 and -2.5 layer 1, 2 or 3. The values 2 and 4 refer to the MPEG-AAC audio encoding schemes.
framedboolean0 or 1A true value indicates that each buffer contains exactly one frame. A false value indicates that frames and buffers do not necessarily match up.
layerinteger1, 2, or 3The compression scheme layer used to compress the data (only if mpegversion=1).
bitrateintegergreater than 0The bitrate, in bits per second. For VBR (variable bitrate) MPEG data, this is the average bitrate.
audio/x-vorbisVorbis audio dataThere are currently no specific properties defined for this type.

现在您可以学习如何构建插件了。在本指南的这一部分中,您将学习如何应用基本的GStreamer编程概念来编写一个简单的插件。指南的前几部分没有包含明确的示例代码,这可能会使事情变得有点抽象和难以理解。相反,本节将通过开发一个名为“MyFilter”的示例音频过滤器插件来展示应用程序和代码。

示例过滤器元件将从单个输入Pag和单个输出Pad开始。过滤器首先只需将媒体和事件数据从其接收器板传递到源板,而无需修改。但是在本部分的末尾,您将学习添加一些更有趣的功能,包括属性和信号处理程序。在阅读了指南的下一部分“高级过滤器概念”之后,您将能够为插件添加更多功能。

3. 构建样板文件

在本章中,您将学习如何为一个新插件构造最简单的代码。从零开始,您将看到如何获取GStreamer模板源代码。然后您将学习如何使用一些基本工具来复制和修改模板插件以创建新插件。如果您遵循这里的示例,那么在本章结束时,您将拥有一个功能强大的音频过滤器插件,可以在GStreamer应用程序中编译和使用。

3.1 获取GStreamer插件模板

目前有两种方法可以为GStreamer开发新的插件:您可以手工编写整个插件,或者您可以复制现有的插件模板并编写所需的插件代码。第二种方法是两种方法中最简单的一种,因此第一种方法在这里甚至不作描述(嗯,也就是说,“这是留给读者的练习。”)

第一步是签出 gst-template 的 git模块的副本,以获得一个重要的工具和基本GStreamer插件的源代码模板。要签出gst-template模块,请确保已连接到internet,并在命令控制台键入以下命令:

lucky@ubuntu:~/temp/gstreamer$  git clone https://gitlab.freedesktop.org/gstreamer/gst-template.git
Cloning into 'gst-template'...
remote: Enumerating objects: 549, done.
remote: Total 549 (delta 0), reused 0 (delta 0), pack-reused 549
Receiving objects: 100% (549/549), 112.11 KiB | 11.00 KiB/s, done.
Resolving deltas: 100% (356/356), done.

这个命令将把一系列文件和目录签出到 gst-template 中。您将使用的模板位于gst-template/gst-plugin/目录中。您应该查看该目录中的文件,以大致了解插件的源代码树的结构。

如果由于某种原因无法访问git存储库,还可以通过gitlab web界面下载最新版本的快照。

3.2 使用项目 Stamp

3.2.1 创建样板代码

创建新元素时要做的第一件事是指定有关它的一些基本细节:它的名称、编写者、版本号等。我们还需要定义一个对象来表示元素并存储元素所需的数据。这些细节统称为样板。

定义样板文件的标准方法只是编写一些代码,并填充一些结构。如前一节所述,最简单的方法是复制模板并根据需要添加功能。为了帮助您这样做,./gst-plugin/tools/目录中有一个工具。这个工具make_ element是一个命令行实用程序,它为您创建样板代码。

要使用make_element,首先打开一个终端窗口。切换到gst-template/gst-plugin/src目录,然后运行make_element命令。make_element的参数是:

插件的名称,以及
工具将使用的源文件。默认情况下,使用 gstplugin。
例如,以下命令基于插件模板创建MyFilter插件,并将输出文件放在gst-template/gst-plugin/src目录中:

shell $ cd gst-template/gst-plugin/src
shell $ ../tools/make_element MyFilter


注意
大写对于插件的名称很重要。请记住,在某些操作系统中,通常在指定目录名和文件名时,大写也很重要。

最后一个命令创建两个文件:gstmyfilter.c 和 gstmyfilter.h。

注意
建议您在继续之前创建 gst-plugin 目录的副本。

3.3 检查基本代码

首先,我们将检查您可能放置在头文件中的代码(尽管由于代码的接口完全由插件系统定义,并且不依赖于读取头文件,这并不重要)

#include <gst/gst.h>

/* Definition of structure storing data for this element. */
typedef struct _GstMyFilter {
  GstElement element;

  GstPad *sinkpad, *srcpad;

  gboolean silent;



} GstMyFilter;

/* Standard definition defining a class for this element. */
typedef struct _GstMyFilterClass {
  GstElementClass parent_class;
} GstMyFilterClass;

/* Standard macros for defining types for this element.  */
#define GST_TYPE_MY_FILTER (gst_my_filter_get_type())
#define GST_MY_FILTER(obj) \
  (G_TYPE_CHECK_INSTANCE_CAST((obj),GST_TYPE_MY_FILTER,GstMyFilter))
#define GST_MY_FILTER_CLASS(klass) \
  (G_TYPE_CHECK_CLASS_CAST((klass),GST_TYPE_MY_FILTER,GstMyFilterClass))
#define GST_IS_MY_FILTER(obj) \
  (G_TYPE_CHECK_INSTANCE_TYPE((obj),GST_TYPE_MY_FILTER))
#define GST_IS_MY_FILTER_CLASS(klass) \
  (G_TYPE_CHECK_CLASS_TYPE((klass),GST_TYPE_MY_FILTER))

/* Standard function returning type information. */
GType gst_my_filter_get_type (void);

GST_ELEMENT_REGISTER_DECLARE(my_filter)

使用此头文件,您可以使用以下宏来设置源文件中的元素基础,以便适当地调用所有函数:

#include "filter.h"

G_DEFINE_TYPE (GstMyFilter, gst_my_filter, GST_TYPE_ELEMENT);
GST_ELEMENT_REGISTER_DEFINE(my_filter, "my-filter", GST_RANK_NONE, GST_TYPE_MY_FILTER);

宏GST_ELEMENT_REGISTER_DEFINE与GST_ELEMENT_REGISTER_DECLARE结合使用,可以通过调用GST_ELEMENT_REGISTER (my_filter)从插件内或任何其他插件/应用程序注册元素。

3.4 元素元数据

元素元数据提供额外的元素信息。它配置有gst_element_class_set_metadata或gst_element_class_set_static_metadata,其参数如下:

  • 元素的英文长名称。
  • 元素的类型,请参阅GStreamer核心源代码树中的docs/additional/design/draft-klass.txt文档以获取详细信息和示例。
  • 元素用途的简要说明。
  • 元素的作者的名称,后跟尖括号中的联系人电子邮件地址(可选)。

例如:

gst_element_class_set_static_metadata (klass,
  "An example plugin",
  "Example/FirstExample",
  "Shows the basic structure of a plugin",
  "your name <your.name@your.isp>");

元素详细信息是在_class_init()函数中注册的,它是GObject系统的一部分。应该在使用glib注册类型的函数中为这个GObject设置_class_init()函数。

static void
gst_my_filter_class_init (GstMyFilterClass * klass)
{
  GstElementClass *element_class = GST_ELEMENT_CLASS (klass);

[..]
  gst_element_class_set_static_metadata (element_class,
    "An example plugin",
    "Example/FirstExample",
    "Shows the basic structure of a plugin",
    "your name <your.name@your.isp>");

}

3.5 GstStaticPadTemplate

GstStaticPadTemplate 是元素将(或可能)创建和使用的 pad 的描述。它包含:

  • Pad 的简称。
  • Pad 方向。
  • 存在属性。这表示 pad 是否总是存在(an “always” pad)、仅在某些情况下存在(a “sometimes” pad)还是仅在应用程序请求此类pad(a “request” pad)时存在。
  • 此元素支持的类型(功能)。

例如:

static GstStaticPadTemplate sink_factory =
GST_STATIC_PAD_TEMPLATE (
  "sink",
  GST_PAD_SINK,
  GST_PAD_ALWAYS,
  GST_STATIC_CAPS ("ANY")
);

这些pad模板在使用 gst_element_class_add_pad_template() 的 _class_init () 函数期间注册。对于此函数,您需要 GstPadTemplate 的句柄,您可以使用 gst_static_pad_template_get() 从静态 pad 模板创建该句柄。详见下文。

pad 是使用 gst_pad_new_from_static_template() 从元素的 _init() 函数中的这些静态模板创建的。为了使用 gst_pad_new_from_static_template() 从这个模板创建一个新的 pad,您需要将 pad 模板声明为一个全局变量。更多关于这个主题在Specifying the pads.。

static GstStaticPadTemplate sink_factory = [..],
    src_factory = [..];

static void
gst_my_filter_class_init (GstMyFilterClass * klass)
{
  GstElementClass *element_class = GST_ELEMENT_CLASS (klass);
[..]

  gst_element_class_add_pad_template (element_class,
    gst_static_pad_template_get (&src_factory));
  gst_element_class_add_pad_template (element_class,
    gst_static_pad_template_get (&sink_factory));
}

最后再讨论一下template的类型或者说它支持的类型 ,在上面的示例中我们使用了ANY,意味着改template可以接收所有类型的输入,在实际使用场景中,你应该设置媒体类型和一些可选的属性来确认template的输入类型。表示应该是一个以媒体类型开头的字符串,然后一组逗号-将属性与其支持的值分开。如果音频过滤器支持任何采样时的原始整数16位音频、单声道或立体声,则模板看起来如下:

static GstStaticPadTemplate sink_factory =
GST_STATIC_PAD_TEMPLATE (
  "sink",
  GST_PAD_SINK,
  GST_PAD_ALWAYS,
  GST_STATIC_CAPS (
    "audio/x-raw, "
      "format = (string) " GST_AUDIO_NE (S16) ", "
      "channels = (int) { 1, 2 }, "
      "rate = (int) [ 8000, 96000 ]"
  )
);

被花括号(“{” and “}”) 包围的值是列表,被方括号(“[” and “]”)所包围的值是范围。 多个类型集也是受支持的,应该用分号(“;”)分隔。在pad一章的最后一节中,我们将看到如何使用类型来知道流的确切格式:Specifying the pads.

3.6 构造函数

每个元素有两个用于构造元素的函数。_class_init() 函数,仅用于初始化类一次(指定类具有哪些信号、参数和虚拟函数,并设置全局状态);以及 _init() 函数,用于初始化此类型的特定实例。

3.6.1 plugin_init 函数

一旦我们编写了定义插件所有部分的代码,我们就需要编写 plugin_init() 函数。这是一个特殊的函数,在加载插件后立即调用,并根据是否正确加载了任何依赖项返回 TRUE 或 FALSE。另外,在这个函数中,插件中任何支持的元素类型都应该被注册。

static gboolean
plugin_init (GstPlugin *plugin)
{
  return GST_ELEMENT_REGISTER (my_filter, plugin);
}

GST_PLUGIN_DEFINE (
  GST_VERSION_MAJOR,
  GST_VERSION_MINOR,
  my_filter,
  "My filter plugin",
  plugin_init,
  VERSION,
  "LGPL",
  "GStreamer",
  "http://gstreamer.net/"
)

请注意,plugin_init返回的信息将被缓存在central registry中。由于这个原因,同样的信息总是由函数返回是很重要的:例如,它不能使元素工厂基于运行时代码而变得不可执行。如果一个元素只能在某些条件下工作(例如,如果声卡没有被其他进程使用),这必须反映在元素无法进入就绪状态(如果不可用的话),而不是试图否认插件存在的插件。 

4. 指定 pads

如前所述,pads 是数据进出元素的端口,这使得它们成为元素创建过程中非常重要的一项。在样板代码中,我们看到了静态 pad 模板如何负责向 element 类注册 pad 模板。在这里,我们将看到如何创建实际的元素,如何使用一个 _event ()-函数来配置特定的格式,以及如何注册函数来让数据流过元素。

在元素的 _init () 函数中,可以从 pad 模板创建 pad,该模板已在 _class_init () 函数的 element 类中注册。创建pad之后,必须设置一个 _chain () 函数指针,该指针将接收和处理 sinkpad 上的输入数据。您还可以选择设置一个 _event () 函数指针和一个 _query () 函数指针。或者,pad 也可以在循环模式下运行,这意味着它们可以自己拉取数据。稍后将详细介绍此主题。在那之后,你必须用元素注册 pad。事情是这样的:

static void
gst_my_filter_init (GstMyFilter *filter)
{
  /* pad through which data comes in to the element */
  filter->sinkpad = gst_pad_new_from_static_template (
    &sink_template, "sink");
  /* pads are configured here with gst_pad_set_*_function () */



  gst_element_add_pad (GST_ELEMENT (filter), filter->sinkpad);

  /* pad through which data goes out of the element */
  filter->srcpad = gst_pad_new_from_static_template (
    &src_template, "src");
  /* pads are configured here with gst_pad_set_*_function () */



  gst_element_add_pad (GST_ELEMENT (filter), filter->srcpad);

  /* properties initial value */
  filter->silent = FALSE;
}

5. 链函数

所有数据都要借助于链式函数来处理。对于简单过滤器,_chain () 函数大多是线性函数,因此每进来一个缓冲区,则要送出去一个缓冲区。下面是一个非常简单的链式功能实现:

static GstFlowReturn gst_my_filter_chain (GstPad    *pad,
                                          GstObject *parent,
                                          GstBuffer *buf);

[..]

static void
gst_my_filter_init (GstMyFilter * filter)
{
[..]
  /* configure chain function on the pad before adding
   * the pad to the element */
  gst_pad_set_chain_function (filter->sinkpad,
      gst_my_filter_chain);
[..]
}

static GstFlowReturn
gst_my_filter_chain (GstPad    *pad,
                     GstObject *parent,
             GstBuffer *buf)
{
  GstMyFilter *filter = GST_MY_FILTER (parent);

  if (!filter->silent)
    g_print ("Have data of size %" G_GSIZE_FORMAT" bytes!\n",
        gst_buffer_get_size (buf));

  return gst_pad_push (filter->srcpad, buf);
}

显然,上面的函数没有什么使用价值。你通常会在那里处理数据,而不是打印进来的数据。但是,请记住,缓冲区并不总是可写的。

在更高级的元素(进行事件处理的元素)中,您可能需要另外指定一个事件处理函数,在发送流事件(如caps、流结束、newsegment、标记等)时将调用该函数。

static void
gst_my_filter_init (GstMyFilter * filter)
{
[..]
  gst_pad_set_event_function (filter->sinkpad,
      gst_my_filter_sink_event);
[..]
}



static gboolean
gst_my_filter_sink_event (GstPad    *pad,
                  GstObject *parent,
                  GstEvent  *event)
{
  GstMyFilter *filter = GST_MY_FILTER (parent);

  switch (GST_EVENT_TYPE (event)) {
    case GST_EVENT_CAPS:
      /* we should handle the format here */
      break;
    case GST_EVENT_EOS:
      /* end-of-stream, we should close down all stream leftovers here */
      gst_my_filter_stop_processing (filter);
      break;
    default:
      break;
  }

  return gst_pad_event_default (pad, parent, event);
}

static GstFlowReturn
gst_my_filter_chain (GstPad    *pad,
             GstObject *parent,
             GstBuffer *buf)
{
  GstMyFilter *filter = GST_MY_FILTER (parent);
  GstBuffer *outbuf;

  outbuf = gst_my_filter_process_data (filter, buf);
  gst_buffer_unref (buf);
  if (!outbuf) {
    /* something went wrong - signal an error */
    GST_ELEMENT_ERROR (GST_ELEMENT (filter), STREAM, FAILED, (NULL), (NULL));
    return GST_FLOW_ERROR;
  }

  return gst_pad_push (filter->srcpad, outbuf);
}

在某些情况下,元素也可以控制输入数据速率。在这种情况下,您可能需要编写一个所谓的基于循环的元素。源元素(只有源代码填充)也可以是基于get的元素。这些概念将在本指南的高级部分以及专门讨论源代码板的部分进行解释。

6. 事件函数

事件函数通知您数据流中发生的特殊事件(如caps、流结束、newsegment、标记等)。事件可以向上游和下游传播,因此您可以在sink pads和source pads上接收它们。

下面是一个非常简单的事件函数,我们将它安装在元素的sink pad上。

static gboolean gst_my_filter_sink_event (GstPad    *pad,
                                          GstObject *parent,
                                          GstEvent  *event);

[..]

static void
gst_my_filter_init (GstMyFilter * filter)
{
[..]
  /* configure event function on the pad before adding
   * the pad to the element */
  gst_pad_set_event_function (filter->sinkpad,
      gst_my_filter_sink_event);
[..]
}

static gboolean
gst_my_filter_sink_event (GstPad    *pad,
                  GstObject *parent,
                  GstEvent  *event)
{
  gboolean ret;
  GstMyFilter *filter = GST_MY_FILTER (parent);

  switch (GST_EVENT_TYPE (event)) {
    case GST_EVENT_CAPS:
      /* we should handle the format here */

      /* push the event downstream */
      ret = gst_pad_push_event (filter->srcpad, event);
      break;
    case GST_EVENT_EOS:
      /* end-of-stream, we should close down all stream leftovers here */
      gst_my_filter_stop_processing (filter);

      ret = gst_pad_event_default (pad, parent, event);
      break;
    default:
      /* just call the default handler */
      ret = gst_pad_event_default (pad, parent, event);
      break;
  }
  return ret;
}

对于未知事件,最好调用默认事件处理程序 gst_pad_event_default ()。根据事件类型,默认处理程序将转发事件或只是取消转发。CAPS 事件在默认情况下是不转发的,因此我们需要自己在事件处理程序中执行此操作。

7. 查询函数

通过 query 函数,元素将接收它必须回复的查询。这些查询包括位置、持续时间以及元素支持的格式和调度模式。查询可以向上游和下游移动,因此您可以在sink pads和source pads上接收它们。

下面是一个非常简单的查询函数,我们安装在元素的源代码板上。

static gboolean gst_my_filter_src_query (GstPad    *pad,
                                         GstObject *parent,
                                         GstQuery  *query);

[..]

static void
gst_my_filter_init (GstMyFilter * filter)
{
[..]
  /* configure event function on the pad before adding
   * the pad to the element */
  gst_pad_set_query_function (filter->srcpad,
      gst_my_filter_src_query);
[..]
}

static gboolean
gst_my_filter_src_query (GstPad    *pad,
                 GstObject *parent,
                 GstQuery  *query)
{
  gboolean ret;
  GstMyFilter *filter = GST_MY_FILTER (parent);

  switch (GST_QUERY_TYPE (query)) {
    case GST_QUERY_POSITION:
      /* we should report the current position */
      [...]
      break;
    case GST_QUERY_DURATION:
      /* we should report the duration here */
      [...]
      break;
    case GST_QUERY_CAPS:
      /* we should report the supported caps here */
      [...]
      break;
    default:
      /* just call the default handler */
      ret = gst_pad_query_default (pad, parent, query);
      break;
  }
  return ret;
}


对于未知查询,最好调用默认查询处理程序 gst_pad_query_default ()。根据查询类型,默认处理程序将转发查询或简单地取消查询。

8. 什么是状态?

状态描述元素实例是否初始化,是否准备好传输数据,以及它是否当前正在处理数据。GStreamer中定义了四种状态:

  • GST_STATE_NULL

  • GST_STATE_READY

  • GST_STATE_PAUSED

  • GST_STATE_PLAYING

从现在起,它将被简单地称为“NULL”、“READY”、“pause”和“PLAYING”。

GST_STATE_NULL 是元素的默认状态。在这种状态下,它没有分配任何运行时资源,它没有加载任何运行时库,显然无法处理数据。

GST_STATE_READY 是元素可以处于的下一个状态。在就绪状态下,元素分配了所有默认资源(运行时库、运行时内存)。但是,它尚未分配或定义任何特定于流的内容。当从NULL转到就绪状态(GST_STATE_CHANGE_NULL_TO_READY)时,元素应该分配任何非流特定资源,并应加载运行时可加载库(如果有)。在另一个方向(从 READY 到 NULL、GST_STATE_CHANGE_READY_TO_NULL)时,元素应该卸载这些库并释放所有分配的资源。这些资源的示例是硬件设备。注意,文件通常是流,因此这些文件应被视为流特定资源;因此,不应在这种状态下分配它们。

GST_STATE_PAUSED 是元素准备接受和处理数据的状态。对于大多数元素,这种状态与播放相同。此规则的唯一例外是 sink 元素。sink元素只接受一个数据缓冲区,然后阻塞。此时管道已“预卷(prerolled)”,并准备立即呈现数据。

GST_STATE_PLAYING 是元素可以处于的最高状态。对于大多数元素,这种状态与暂停完全相同,它们接受并处理带有数据的事件和缓冲区。只有接收器元素需要区分暂停状态和播放状态。在播放状态下,接收器元素实际上呈现传入数据,例如将音频输出到声卡或将视频图片渲染到图像接收器。

9. 添加属性

控制元素行为的主要也是最重要的方法是通过 GObject 属性。GObject 属性在 _class_init () 函数中定义。元素可选地实现了一个 _get_property () 和一个 _set_property () 函数。如果应用程序更改或请求某个属性的值,则会通知这些函数,然后可以填写该值或执行该属性内部更改值所需的操作。

您可能还希望保留一个实例变量,其中包含在 get 和 set 函数中使用的属性的当前配置值。请注意,GObject 不会自动将实例变量设置为默认值,您必须在元素的 _init () 函数中执行此操作。

/* properties */
enum {
  PROP_0,
  PROP_SILENT
  /* FILL ME */
};

static void gst_my_filter_set_property  (GObject      *object,
                         guint         prop_id,
                         const GValue *value,
                         GParamSpec   *pspec);
static void gst_my_filter_get_property  (GObject      *object,
                         guint         prop_id,
                         GValue       *value,
                         GParamSpec   *pspec);

static void
gst_my_filter_class_init (GstMyFilterClass *klass)
{
  GObjectClass *object_class = G_OBJECT_CLASS (klass);

  /* define virtual function pointers */
  object_class->set_property = gst_my_filter_set_property;
  object_class->get_property = gst_my_filter_get_property;

  /* define properties */
  g_object_class_install_property (object_class, PROP_SILENT,
    g_param_spec_boolean ("silent", "Silent",
              "Whether to be very verbose or not",
              FALSE, G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
}

static void
gst_my_filter_set_property (GObject      *object,
                guint         prop_id,
                const GValue *value,
                GParamSpec   *pspec)
{
  GstMyFilter *filter = GST_MY_FILTER (object);

  switch (prop_id) {
    case PROP_SILENT:
      filter->silent = g_value_get_boolean (value);
      g_print ("Silent argument was changed to %s\n",
           filter->silent ? "true" : "false");
      break;
    default:
      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
      break;
  }
}

static void
gst_my_filter_get_property (GObject    *object,
                guint       prop_id,
                GValue     *value,
                GParamSpec *pspec)
{
  GstMyFilter *filter = GST_MY_FILTER (object);

  switch (prop_id) {
    case PROP_SILENT:
      g_value_set_boolean (value, filter->silent);
      break;
    default:
      G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
      break;
  }
}

以上是如何使用属性的一个非常简单的示例。图形应用程序将使用这些属性,并将显示一个用户可控制的小部件,可以用它来更改这些属性。这意味着--为了使属性尽可能的用户友好--您应该尽可能精确地定义属性。不仅在定义有效属性可以定位的范围(对于整数、浮点等),而且在属性定义中使用非常描述性(更好的是:国际化)的字符串,如果可能的话,使用枚举和标志代替整数。GObject 文档以一种非常完整的方式描述了这些,但是下面,我们将给出一个简短的示例,说明这在哪里是有用的。请注意,在这里使用整数可能会完全迷惑用户,因为在这种情况下它们毫无意义。这个例子是从 videotestsrc 偷来的。

typedef enum {
  GST_VIDEOTESTSRC_SMPTE,
  GST_VIDEOTESTSRC_SNOW,
  GST_VIDEOTESTSRC_BLACK
} GstVideotestsrcPattern;

[..]

#define GST_TYPE_VIDEOTESTSRC_PATTERN (gst_videotestsrc_pattern_get_type ())
static GType
gst_videotestsrc_pattern_get_type (void)
{
  static GType videotestsrc_pattern_type = 0;

  if (!videotestsrc_pattern_type) {
    static GEnumValue pattern_types[] = {
      { GST_VIDEOTESTSRC_SMPTE, "SMPTE 100% color bars",    "smpte" },
      { GST_VIDEOTESTSRC_SNOW,  "Random (television snow)", "snow"  },
      { GST_VIDEOTESTSRC_BLACK, "0% Black",                 "black" },
      { 0, NULL, NULL },
    };

    videotestsrc_pattern_type =
    g_enum_register_static ("GstVideotestsrcPattern",
                pattern_types);
  }

  return videotestsrc_pattern_type;
}

[..]

static void
gst_videotestsrc_class_init (GstvideotestsrcClass *klass)
{
[..]
  g_object_class_install_property (G_OBJECT_CLASS (klass), PROP_PATTERN,
    g_param_spec_enum ("pattern", "Pattern",
               "Type of test pattern to generate",
                       GST_TYPE_VIDEOTESTSRC_PATTERN, GST_VIDEOTESTSRC_SMPTE,
                       G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
[..]
}

10. 信号

GObject 信号可用于通知应用程序特定于此对象的事件。但是,请注意,应用程序需要知道信号及其含义,因此如果您正在寻找应用程序元素交互的通用方法,那么信号可能不是您要寻找的。然而,在许多情况下,信号可能非常有用。有关信号的所有内部信息,请参阅 GObject documentation 。

11. 构建测试应用程序

通常,您会希望在尽可能小的设置中测试新编写的插件。通常,gst-launch-1.0 是测试插件的第一步。如果您没有在 GStreamer 搜索的目录中安装插件,那么您需要设置插件路径。将 GST_PLUGIN_PATH 设置为包含插件的目录,或者使用命令行选项 ---gst-plugin-path 。如果你的插件是基于 gst-plugin 模板的,那么这看起来像 gst-launch-1.0 --gst-plugin-path=$HOME/gst-template/gst-plugin/src/.libs TESTPIPELINE,然而,你通常需要比 gst-launch-1.0 更多的测试特性,比如查找、事件、交互性等等。编写自己的小型测试程序是实现这一点的最简单方法。本节用几句话解释了如何做到这一点。有关完整的应用程序开发指南,请参阅应用程序开发手册。

首先,需要通过调用 gst_init () 初始化 GStreamer 核心库。您也可以调用 gst_init_get_option_group (),它将返回一个指向 GOptionGroup 的指针。然后可以使用 GOption 来处理初始化,这将完成 GStreamer 初始化。

可以使用 gst_element_factory_make () 创建元素,其中第一个参数是要创建的元素类型,第二个参数是自由格式名称。最后的示例使用了一个简单的 filesource - decoder - soundcard 输出管道,但是如果需要,可以使用特定的调试元素。例如,可以在管道的中间使用一个标识元素作为应用程序发送器的数据。这可用于检查测试应用程序中的错误行为或正确性数据。此外,还可以使用管道末尾的 fakesink 元素将数据转储到 stdout(为此,请将 dump 属性设置为TRUE)。最后,可以使用 valgrind 检查内存错误。

在链接过程中,您的测试应用程序可以使用过滤的 caps 作为一种方式来驱动特定类型的数据进出您的元素。这是检查元素中多种类型的输入和输出的一种非常简单有效的方法。

请注意,在运行过程中,您至少应该侦听总线和/或插件/元素上的 “error” 和 “eos” 消息,以检查是否对此进行了正确处理。此外,您应该将事件添加到管道中,并确保您的插件正确处理这些事件(关于时钟、内部缓存等)。

永远不要忘记清理插件或测试应用程序中的内存。当进入空状态时,元素应该清理分配的内存和缓存。同时,它应该关闭所有可能的支持库的引用。您的应用程序应该 unref () 管道,并确保它不会崩溃。

#include <gst/gst.h>

static gboolean
bus_call (GstBus     *bus,
      GstMessage *msg,
      gpointer    data)
{
  GMainLoop *loop = data;

  switch (GST_MESSAGE_TYPE (msg)) {
    case GST_MESSAGE_EOS:
      g_print ("End-of-stream\n");
      g_main_loop_quit (loop);
      break;
    case GST_MESSAGE_ERROR: {
      gchar *debug = NULL;
      GError *err = NULL;

      gst_message_parse_error (msg, &err, &debug);

      g_print ("Error: %s\n", err->message);
      g_error_free (err);

      if (debug) {
        g_print ("Debug details: %s\n", debug);
        g_free (debug);
      }

      g_main_loop_quit (loop);
      break;
    }
    default:
      break;
  }

  return TRUE;
}

gint
main (gint   argc,
      gchar *argv[])
{
  GstStateChangeReturn ret;
  GstElement *pipeline, *filesrc, *decoder, *filter, *sink;
  GstElement *convert1, *convert2, *resample;
  GMainLoop *loop;
  GstBus *bus;
  guint watch_id;

  /* initialization */
  gst_init (&argc, &argv);
  loop = g_main_loop_new (NULL, FALSE);
  if (argc != 2) {
    g_print ("Usage: %s <mp3 filename>\n", argv[0]);
    return 01;
  }

  /* create elements */
  pipeline = gst_pipeline_new ("my_pipeline");

  /* watch for messages on the pipeline's bus (note that this will only
   * work like this when a GLib main loop is running) */
  bus = gst_pipeline_get_bus (GST_PIPELINE (pipeline));
  watch_id = gst_bus_add_watch (bus, bus_call, loop);
  gst_object_unref (bus);

  filesrc  = gst_element_factory_make ("filesrc", "my_filesource");
  decoder  = gst_element_factory_make ("mad", "my_decoder");

  /* putting an audioconvert element here to convert the output of the
   * decoder into a format that my_filter can handle (we are assuming it
   * will handle any sample rate here though) */
  convert1 = gst_element_factory_make ("audioconvert", "audioconvert1");

  /* use "identity" here for a filter that does nothing */
  filter   = gst_element_factory_make ("my_filter", "my_filter");

  /* there should always be audioconvert and audioresample elements before
   * the audio sink, since the capabilities of the audio sink usually vary
   * depending on the environment (output used, sound card, driver etc.) */
  convert2 = gst_element_factory_make ("audioconvert", "audioconvert2");
  resample = gst_element_factory_make ("audioresample", "audioresample");
  sink     = gst_element_factory_make ("pulsesink", "audiosink");

  if (!sink || !decoder) {
    g_print ("Decoder or output could not be found - check your install\n");
    return -1;
  } else if (!convert1 || !convert2 || !resample) {
    g_print ("Could not create audioconvert or audioresample element, "
             "check your installation\n");
    return -1;
  } else if (!filter) {
    g_print ("Your self-written filter could not be found. Make sure it "
             "is installed correctly in $(libdir)/gstreamer-1.0/ or "
             "~/.gstreamer-1.0/plugins/ and that gst-inspect-1.0 lists it. "
             "If it doesn't, check with 'GST_DEBUG=*:2 gst-inspect-1.0' for "
             "the reason why it is not being loaded.");
    return -1;
  }

  g_object_set (G_OBJECT (filesrc), "location", argv[1], NULL);

  gst_bin_add_many (GST_BIN (pipeline), filesrc, decoder, convert1, filter,
                    convert2, resample, sink, NULL);

  /* link everything together */
  if (!gst_element_link_many (filesrc, decoder, convert1, filter, convert2,
                              resample, sink, NULL)) {
    g_print ("Failed to link one or more elements!\n");
    return -1;
  }

  /* run */
  ret = gst_element_set_state (pipeline, GST_STATE_PLAYING);
  if (ret == GST_STATE_CHANGE_FAILURE) {
    GstMessage *msg;

    g_print ("Failed to start up pipeline!\n");

    /* check if there is an error message with details on the bus */
    msg = gst_bus_poll (bus, GST_MESSAGE_ERROR, 0);
    if (msg) {
      GError *err = NULL;

      gst_message_parse_error (msg, &err, NULL);
      g_print ("ERROR: %s\n", err->message);
      g_error_free (err);
      gst_message_unref (msg);
    }
    return -1;
  }

  g_main_loop_run (loop);

  /* clean up */
  gst_element_set_state (pipeline, GST_STATE_NULL);
  gst_object_unref (pipeline);
  g_source_remove (watch_id);
  g_main_loop_unref (loop);

  return 0;
}

参考

https://blog.csdn.net/quicmous/article/details/116563437

Plugin Writer's Guide (gstreamer.freedesktop.org)

  • 0
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
gstreamer一个用于处理多媒体数据的框架,它提供了一系列的插件来实现音视频数据的采集、转换、编解码、过滤、显示等功能。如果需要实现特定的功能,我们可以使用gstreamer插件开发扩展gstreamer的功能。 首先,我们需要了解gstreamer插件的类型。gstreamer插件主要分为元件插件(element plug-in)和扩展插件(extension plug-in)两种。 元件插件主要用于实现音视频数据的处理,如采集、解码、过滤、转换等。而扩展插件则用于提供其他不属于元件插件的功能,如sink、source、codec、protocol等。 其次,我们需要了解gstreamer插件的开发步骤。在开发过程中,我们需要先基于gstreamer提供的开发库进行开发。开发的过程中需要实现插件的指定功能,可以通过编写C/C++代码的方式实现。对于元件插件,需要实现对应的gst_element_class结构体; 对于扩展插件,需要实现对应的GstPlugin结构体。完成插件的开发后,通过编译、安装等步骤将插件集成到gstreamer中。 最后,我们需要注意一些开发细节。在开发插件时,需要考虑插件的性能、稳定性、易用性等方面。需要注意内存泄漏等问题,以及错误处理和日志输出等,方便调试和排查问题。 总之,gstreamer插件开发是一项有挑战的工作,需要我们对gstreamer的内部原理和机制有较深的了解。如果能够熟练掌握gstreamer的开发技巧和方法,可以大大扩展其功能和适用范围。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值