GStreamer Tutorial 中文翻译:Basic tutorial 3: Dynamic pipelines

GStreamer Tutorial 3中文翻译



前言

由于工作原因,用的GStreamer的图像解码库,所以记录GStreamer Tutorial 的中文翻译和个人的理解以便学习。若有不足请多指教。侵删。


Basic tutorial 3: Dynamic pipelines

目的

本教程展示了使用GStreamer所需的其余基本概念,这些概念允许在信息可用时 "即时 "构建管道,而不是在你的应用程序开始时定义一个单一的管道。
本教程结束后,你将掌握必要的知识,开始播放教程。这里回顾的要点将是:

  • 如何在连接元素时达到更精细的控制;
  • 如何得到感兴趣的事件通知,以便你能及时作出反应;
  • 一个元素可以处于的各种状态。

简介

正如你即将看到的,本教程中的管道在被设置为播放状态之前并没有完全建立。这是好的。如果我们不采取进一步的行动,数据将到达管道的末端,管道将产生一个错误信息并停止。但我们要采取进一步的行动…
在这个例子中,我们正在打开一个多路复用(或多路复用)的文件,也就是说,音频和视频一起存储在一个容器文件中。负责打开这种容器的元素被称为解复用器,容器格式的一些例子是Matroska(MKV)、Quick Time(QT、MOV)、Ogg或高级系统格式(ASF、WMV、WMA)。
如果一个容器嵌入了多个流(例如,一个视频和两个音轨),解复用器将把它们分开,并通过不同的输出端口暴露出来。通过这种方式,可以在管道创建不同的分支,处理不同类型的数据。
GStreamer元素之间相互通信的端口被称为pad(GstPad)。存在着数据进入一个元素的sink pads和数据离开一个元素的source pads。自然而然地,源只包含source pads,sink元素只包含sink pads,而过滤元素则两者都包含。
请添加图片描述
请添加图片描述
请添加图片描述
一个解复用器包含一个经过复用的数据的sink pad,以及多个source pads,容器中的每个流都有一个srouce pad。
请添加图片描述
为了完整起见,这里有一个简化的管道,包含一个解复用器和两个分支,一个用于音频,一个用于视频。这并不是本例中将要建立的管道。
请添加图片描述
在处理解复用器时,主要的复杂性在于它们在收到一些数据并有机会查看容器内部的内容之前,不能产生任何信息。这就是,解复用器开始时没有其他元素可以链接的源垫,因此,管道必须在它们那里终止。
解决方案是建立从源头到解复用器的管道,并将其设置为运行(播放)。当demuxer收到足够的信息,知道管道中流的数量和种类时,它将开始创建source pads。这是我们完成构建管道并将其连接到新添加的demuxer pads的正确时间。
为了简单起见,在这个例子中,我们将只链接到音频pad,而忽略视频。

动态Hello World

把这段代码复制到一个名为basic-tutorial-3.c的文本文件中(或者在你的GStreamer安装中找到它)。
basic-tutorial-3.c

#include <gst/gst.h>

/* Structure to contain all our information, so we can pass it to callbacks */
typedef struct _CustomData {
  GstElement *pipeline;
  GstElement *source;
  GstElement *convert;
  GstElement *resample;
  GstElement *sink;
} CustomData;

/* Handler for the pad-added signal */
static void pad_added_handler (GstElement *src, GstPad *pad, CustomData *data);

int main(int argc, char *argv[]) {
  CustomData data;
  GstBus *bus;
  GstMessage *msg;
  GstStateChangeReturn ret;
  gboolean terminate = FALSE;

  /* Initialize GStreamer */
  gst_init (&argc, &argv);

  /* Create the elements */
  data.source = gst_element_factory_make ("uridecodebin", "source");
  data.convert = gst_element_factory_make ("audioconvert", "convert");
  data.resample = gst_element_factory_make ("audioresample", "resample");
  data.sink = gst_element_factory_make ("autoaudiosink", "sink");

  /* Create the empty pipeline */
  data.pipeline = gst_pipeline_new ("test-pipeline");

  if (!data.pipeline || !data.source || !data.convert || !data.resample || !data.sink) {
    g_printerr ("Not all elements could be created.\n");
    return -1;
  }

  /* Build the pipeline. Note that we are NOT linking the source at this
   * point. We will do it later. */
  gst_bin_add_many (GST_BIN (data.pipeline), data.source, data.convert, data.resample, data.sink, NULL);
  if (!gst_element_link_many (data.convert, data.resample, data.sink, NULL)) {
    g_printerr ("Elements could not be linked.\n");
    gst_object_unref (data.pipeline);
    return -1;
  }

  /* Set the URI to play */
  g_object_set (data.source, "uri", "https://www.freedesktop.org/software/gstreamer-sdk/data/media/sintel_trailer-480p.webm", NULL);

  /* Connect to the pad-added signal */
  g_signal_connect (data.source, "pad-added", G_CALLBACK (pad_added_handler), &data);

  /* Start playing */
  ret = gst_element_set_state (data.pipeline, GST_STATE_PLAYING);
  if (ret == GST_STATE_CHANGE_FAILURE) {
    g_printerr ("Unable to set the pipeline to the playing state.\n");
    gst_object_unref (data.pipeline);
    return -1;
  }

  /* Listen to the bus */
  bus = gst_element_get_bus (data.pipeline);
  do {
    msg = gst_bus_timed_pop_filtered (bus, GST_CLOCK_TIME_NONE,
        GST_MESSAGE_STATE_CHANGED | GST_MESSAGE_ERROR | GST_MESSAGE_EOS);

    /* Parse message */
    if (msg != NULL) {
      GError *err;
      gchar *debug_info;

      switch (GST_MESSAGE_TYPE (msg)) {
        case GST_MESSAGE_ERROR:
          gst_message_parse_error (msg, &err, &debug_info);
          g_printerr ("Error received from element %s: %s\n", GST_OBJECT_NAME (msg->src), err->message);
          g_printerr ("Debugging information: %s\n", debug_info ? debug_info : "none");
          g_clear_error (&err);
          g_free (debug_info);
          terminate = TRUE;
          break;
        case GST_MESSAGE_EOS:
          g_print ("End-Of-Stream reached.\n");
          terminate = TRUE;
          break;
        case GST_MESSAGE_STATE_CHANGED:
          /* We are only interested in state-changed messages from the pipeline */
          if (GST_MESSAGE_SRC (msg) == GST_OBJECT (data.pipeline)) {
            GstState old_state, new_state, pending_state;
            gst_message_parse_state_changed (msg, &old_state, &new_state, &pending_state);
            g_print ("Pipeline state changed from %s to %s:\n",
                gst_element_state_get_name (old_state), gst_element_state_get_name (new_state));
          }
          break;
        default:
          /* We should not reach here */
          g_printerr ("Unexpected message received.\n");
          break;
      }
      gst_message_unref (msg);
    }
  } while (!terminate);

  /* Free resources */
  gst_object_unref (bus);
  gst_element_set_state (data.pipeline, GST_STATE_NULL);
  gst_object_unref (data.pipeline);
  return 0;
}

/* This function will be called by the pad-added signal */
static void pad_added_handler (GstElement *src, GstPad *new_pad, CustomData *data) {
  GstPad *sink_pad = gst_element_get_static_pad (data->convert, "sink");
  GstPadLinkReturn ret;
  GstCaps *new_pad_caps = NULL;
  GstStructure *new_pad_struct = NULL;
  const gchar *new_pad_type = NULL;

  g_print ("Received new pad '%s' from '%s':\n", GST_PAD_NAME (new_pad), GST_ELEMENT_NAME (src));

  /* If our converter is already linked, we have nothing to do here */
  if (gst_pad_is_linked (sink_pad)) {
    g_print ("We are already linked. Ignoring.\n");
    goto exit;
  }

  /* Check the new pad's type */
  new_pad_caps = gst_pad_get_current_caps (new_pad);
  new_pad_struct = gst_caps_get_structure (new_pad_caps, 0);
  new_pad_type = gst_structure_get_name (new_pad_struct);
  if (!g_str_has_prefix (new_pad_type, "audio/x-raw")) {
    g_print ("It has type '%s' which is not raw audio. Ignoring.\n", new_pad_type);
    goto exit;
  }

  /* Attempt the link */
  ret = gst_pad_link (new_pad, sink_pad);
  if (GST_PAD_LINK_FAILED (ret)) {
    g_print ("Type is '%s' but link failed.\n", new_pad_type);
  } else {
    g_print ("Link succeeded (type '%s').\n", new_pad_type);
  }

exit:
  /* Unreference the new pad's caps, if we got them */
  if (new_pad_caps != NULL)
    gst_caps_unref (new_pad_caps);

  /* Unreference the sink pad */
  gst_object_unref (sink_pad);
}

代码走读

/* Structure to contain all our information, so we can pass it to callbacks */
typedef struct _CustomData {
  GstElement *pipeline;
  GstElement *source;
  GstElement *convert;
  GstElement *resample;
  GstElement *sink;
} CustomData;

到目前为止,我们已经将所有我们需要的信息(基本上是指向GstElements的指针)作为局部变量。由于本教程(以及大多数实际应用)涉及到回调,我们将把所有的数据归入一个结构,以便于处理。

/* Handler for the pad-added signal */
static void pad_added_handler (GstElement *src, GstPad *pad, CustomData *data);

这是一个前瞻性的参考,将在以后使用。

/* Create the elements */
data.source = gst_element_factory_make ("uridecodebin", "source");
data.convert = gst_element_factory_make ("audioconvert", "convert");
data.resample = gst_element_factory_make ("audioresample", "resample");
data.sink = gst_element_factory_make ("autoaudiosink", "sink");

uridecodebin将在内部实例化所有必要的元素(信号源、解扰器和解码器),把URI变成原始音频/视频流。它所做的工作是playbin所做的一半。由于它包含解复用器,它的source pad最初是不可用的,我们将需要即时链接到它们。
audioresample对于不同音频采样率之间的转换非常有用,同样确保这个例子可以在任何平台上工作,因为音频解码器产生的音频采样率可能不是音频sink支持的。
autoaudiosink相当于前一个教程中的autovideosink,用于音频。它将把音频流渲染到音频卡上。

if (!gst_element_link_many (data.convert, data.resample, data.sink, NULL)) {
  g_printerr ("Elements could not be linked.\n");
  gst_object_unref (data.pipeline);
  return -1;
}

在这里,我们把转换器、重采样和sink等元素连接起来,但我们不把它们与源连接起来,因为在此时,它不包含源pad。我们只是让这个分支(转换器+sink)不被链接,直到以后。

/* Set the URI to play */
g_object_set (data.source, "uri", "https://www.freedesktop.org/software/gstreamer-sdk/data/media/sintel_trailer-480p.webm", NULL);

我们通过一个属性设置要播放的文件的URI,就像我们在上一个教程中做的那样。

信号

/* Connect to the pad-added signal */
g_signal_connect (data.source, "pad-added", G_CALLBACK (pad_added_handler), &data);

GSignals是GStreamer的一个关键点。它们允许你在感兴趣的事情发生时得到通知(通过回调的方式)。信号由一个名称来识别,每个GObject都有自己的信号。
在这一行中,我们正在附加到我们的源(一个uridecodebin元素)的 "pad-added "信号。为此,我们使用g_signal_connect()并提供要使用的回调函数(pad_added_handler)和一个数据指针。GStreamer对这个数据指针不做任何处理,它只是把它转发给回调函数,这样我们就可以和它分享信息。在这种情况下,我们传递一个指针到我们专门为此目的而建立的CustomData结构。
我们现在已经准备好了! 只需将管道设置为播放状态,并开始监听总线上的感兴趣的信息(如ERROR或EOS),就像前面的教程中一样。

回调函数

当我们的源元素最终有足够的信息开始产生数据时,它将创建source pad,并触发 "pad-added "信号。在这一点上,我们的回调将被调用。

static void pad_added_handler (GstElement *src, GstPad *new_pad, CustomData *data) {

src是触发信号的元素。在这个例子中,它只能是uridecodebin,因为它是我们唯一连接的信号。信号处理程序的第一个参数始终是触发它的对象。
new_pad是刚刚被添加到src元素中的GstPad。这通常是我们要链接的pad。
data是我们在附加到信号时提供的指针。在这个例子中,我们用它来传递CustomData的指针。

GstPad *sink_pad = gst_element_get_static_pad (data->convert, "sink");

我们从CustomData中提取转换器元素,然后用gst_element_get_static_pad()检索它的sink pad。这就是我们要链接new_pad的pad。在前面的教程中,我们将元素与元素连接起来,并让GStreamer选择适当的pad。现在我们要直接链接pad。

/* If our converter is already linked, we have nothing to do here */
if (gst_pad_is_linked (sink_pad)) {
  g_print ("We are already linked. Ignoring.\n");
  goto exit;
}

uridecodebin可以创建它认为合适的pad,对于每个pad,这个回调将被调用。这几行代码将防止我们在已经链接的情况下试图链接到一个新的pad。

/* Check the new pad's type */
new_pad_caps = gst_pad_get_current_caps (new_pad, NULL);
new_pad_struct = gst_caps_get_structure (new_pad_caps, 0);
new_pad_type = gst_structure_get_name (new_pad_struct);
if (!g_str_has_prefix (new_pad_type, "audio/x-raw")) {
  g_print ("It has type '%s' which is not raw audio. Ignoring.\n", new_pad_type);
  goto exit;
}

现在我们将检查这个new pad要输出的数据类型,因为我们只对产生音频的pad感兴趣。我们之前已经创建了一个处理音频的管道(一个与audioresample和autoaudiosink相连的audioconvert),我们将不能把它连接到生产视频的pad上。
gst_pad_get_current_caps()检索pad的当前能力(也就是它当前输出的数据种类),它被包装在一个GstCaps结构中。一个pad所能支持的所有可能的Caps可以用gst_pad_query_caps()来查询。一个pad可以提供许多Caps,因此GstCaps可以包含许多GstStructure,每个都代表不同的能力。一个pad上的当前Caps总是有一个GstStructure,并代表一个媒体格式,或者如果没有当前Caps,将返回NULL。
因为在这种情况下,我们知道我们想要的pad只有一种Caps(音频),所以我们用gst_caps_get_structure()检索出第一个GstStructure。
最后,通过gst_structure_get_name(),我们恢复了结构的名称,它包含了对格式的主要描述(实际上是其媒体类型)。
如果名字不是audio/x-raw,这不是一个解码的音频pad,我们对它不感兴趣。
否则,请尝试链接:

/* Attempt the link */
ret = gst_pad_link (new_pad, sink_pad);
if (GST_PAD_LINK_FAILED (ret)) {
  g_print ("Type is '%s' but link failed.\n", new_pad_type);
} else {
  g_print ("Link succeeded (type '%s').\n", new_pad_type);
}

gst_pad_link()试图连接两个pad。与gst_element_link()的情况一样,链接必须指定从源头到目的,并且两个pad必须由在同一bin(或管道)中的元素拥有。
然后我们就完成了! 当一个合适的pad出现时,它将被链接到音频处理管道的其他部分,执行将继续进行,直到ERROR或EOS。然而,我们将通过介绍状态的概念从这个教程中挤出更多的内容。

GStreamer 状态

我们已经谈了一些关于状态的问题,我们说,只有当你把管道带到播放状态时,播放才会开始。我们将在这里介绍其余的状态和它们的意义。在GStreamer中有4种状态。

状态描述
NULL 一个元素的NULL状态或初始状态。
READY该元素已准备好进入PAUSED。
PAUSED元素暂停,它准备接受和处理数据。然而,sink元素只接受一个缓冲区,然后进行阻塞。
PLAYING元素在播放,时钟在运行,数据在流动。

你只能在相邻的之间移动,也就是说,你不能从NULL到PLAYING,你必须要经过中间的READY和PAUSED状态。不过,如果你把管道设置为PLAYING,GStreamer就会为你做中间转换。

case GST_MESSAGE_STATE_CHANGED:
  /* We are only interested in state-changed messages from the pipeline */
  if (GST_MESSAGE_SRC (msg) == GST_OBJECT (data.pipeline)) {
    GstState old_state, new_state, pending_state;
    gst_message_parse_state_changed (msg, &old_state, &new_state, &pending_state);
    g_print ("Pipeline state changed from %s to %s:\n",
        gst_element_state_get_name (old_state), gst_element_state_get_name (new_state));
  }
  break;

我们添加了这段代码,监听有关状态变化的总线消息,并将其打印在屏幕上,以帮助你理解转换过程。每个元素都会在总线上发布关于其当前状态的消息,所以我们把它们过滤掉,只听来自管道的消息。
大多数应用程序只需要担心进入PLAYING开始播放,然后进入PAUSED执行暂停,然后在程序退出时回到NULL释放所有资源。

练习

对于很多程序员来说,动态pad链接历来是一个困难的话题。通过实例化一个autovideosink(可能前面有一个videoconvert),并在正确的pad出现时将其链接到demuxer,证明你已经达到了它的掌握程度。提示:你已经在屏幕上打印视频垫的类型。
现在你应该看到(和听到)与基础教程1中相同的电影:Hello world! 在该教程中,你使用了playbin,它是一个方便的元素,自动为你处理所有的解复用和pad链接。大部分的播放教程都是关于playbin的。
PS:

#include <gst/gst.h>

/* Structure to contain all our information, so we can pass it to callbacks */
typedef struct _CustomData {
  GstElement *pipeline;
  GstElement *source;
  GstElement *audio_convert;
  GstElement *video_convert;
  GstElement *resample;
  GstElement *audio_sink;
  GstElement *video_sink;
} CustomData;

/* Handler for the pad-added signal */
static void pad_added_handler (GstElement *src, GstPad *pad, CustomData *data);

int main(int argc, char *argv[]) {
  CustomData data;
  GstBus *bus;
  GstMessage *msg;
  GstStateChangeReturn ret;
  gboolean terminate = FALSE;

  /* Initialize GStreamer */
  gst_init (&argc, &argv);

  /* Create the elements */
  data.source = gst_element_factory_make ("uridecodebin", "source");
  data.audio_convert = gst_element_factory_make ("audioconvert", "audio_convert");
  data.resample = gst_element_factory_make ("audioresample", "resample");
  data.audio_sink = gst_element_factory_make ("autoaudiosink", "audio_sink");

  data.video_convert = gst_element_factory_make ("videoconvert", "video_convert");
  data.video_sink = gst_element_factory_make ("autovideosink", "video_sink");

  /* Create the empty pipeline */
  data.pipeline = gst_pipeline_new ("test-pipeline");
  if (!data.pipeline || !data.source || !data.audio_convert || !data.resample ||\
          !data.audio_sink || !data.video_convert || !data.video_sink) {
    g_printerr ("Not all elements could be created.\n");
    return -1;
  }

  /* Build the pipeline. Note that we are NOT linking the source at this
   * point. We will do it later. */
  gst_bin_add_many (GST_BIN (data.pipeline), data.source, data.audio_convert, \
                    data.resample, data.audio_sink,  data.video_convert, data.video_sink, NULL);
  if (!gst_element_link_many (data.audio_convert, data.resample, data.audio_sink, NULL)) {
    g_printerr ("Elements could not be linked1.\n");
    gst_object_unref (data.pipeline);
    return -1;
  }
  if (!gst_element_link (data.video_convert, data.video_sink)) {
    g_printerr ("Elements could not be linked2.\n");
    gst_object_unref (data.pipeline);
    return -1;
  }
  /* Set the URI to play */
  g_object_set (data.source, "uri", "https://www.freedesktop.org/software/gstreamer-sdk/data/media/sintel_trailer-480p.webm", NULL);

  /* Connect to the pad-added signal */
  g_signal_connect (data.source, "pad-added", G_CALLBACK (pad_added_handler), &data);

  /* Start playing */
  ret = gst_element_set_state (data.pipeline, GST_STATE_PLAYING);
  if (ret == GST_STATE_CHANGE_FAILURE) {
    g_printerr ("Unable to set the pipeline to the playing state.\n");
    gst_object_unref (data.pipeline);
    return -1;
  }

  /* Listen to the bus */
  bus = gst_element_get_bus (data.pipeline);
  do {
    msg = gst_bus_timed_pop_filtered (bus, GST_CLOCK_TIME_NONE,
        GST_MESSAGE_STATE_CHANGED | GST_MESSAGE_ERROR | GST_MESSAGE_EOS);

    /* Parse message */
    if (msg != NULL) {
      GError *err;
      gchar *debug_info;

      switch (GST_MESSAGE_TYPE (msg)) {
        case GST_MESSAGE_ERROR:
          gst_message_parse_error (msg, &err, &debug_info);
          g_printerr ("Error received from element %s: %s\n", GST_OBJECT_NAME (msg->src), err->message);
          g_printerr ("Debugging information: %s\n", debug_info ? debug_info : "none");
          g_clear_error (&err);
          g_free (debug_info);
          terminate = TRUE;
          break;
        case GST_MESSAGE_EOS:
          g_print ("End-Of-Stream reached.\n");
          terminate = TRUE;
          break;
        case GST_MESSAGE_STATE_CHANGED:
          /* We are only interested in state-changed messages from the pipeline */
          if (GST_MESSAGE_SRC (msg) == GST_OBJECT (data.pipeline)) {
            GstState old_state, new_state, pending_state;
            gst_message_parse_state_changed (msg, &old_state, &new_state, &pending_state);
            g_print ("Pipeline state changed from %s to %s:\n",
                gst_element_state_get_name (old_state), gst_element_state_get_name (new_state));
          }
          break;
        default:
          /* We should not reach here */
          g_printerr ("Unexpected message received.\n");
          break;
      }
      gst_message_unref (msg);
    }
  } while (!terminate);

  /* Free resources */
  gst_object_unref (bus);
  gst_element_set_state (data.pipeline, GST_STATE_NULL);
  gst_object_unref (data.pipeline);
  return 0;
}

/* This function will be called by the pad-added signal */
static void pad_added_handler (GstElement *src, GstPad *new_pad, CustomData *data) {
  GstPad *audio_sink_pad = gst_element_get_static_pad (data->audio_convert, "sink");
  GstPad *video_sink_pad = gst_element_get_static_pad (data->video_convert, "sink");
  GstPadLinkReturn ret;
  GstCaps *new_pad_caps = NULL;
  GstStructure *new_pad_struct = NULL;
  const gchar *new_pad_type = NULL;

  g_print ("Received new pad '%s' from '%s':\n", GST_PAD_NAME (new_pad), GST_ELEMENT_NAME (src));

  /* If our converter is already linked, we have nothing to do here */
  if (gst_pad_is_linked (audio_sink_pad) &&gst_pad_is_linked ( video_sink_pad)) {
    g_print ("We are already linked. Ignoring.\n");
    goto exit;
  }

  /* Check the new pad's type */
  new_pad_caps = gst_pad_get_current_caps (new_pad);
  new_pad_struct = gst_caps_get_structure (new_pad_caps, 0);
  new_pad_type = gst_structure_get_name (new_pad_struct);
  if (g_str_has_prefix (new_pad_type, "audio/x-raw")) {
      /* Attempt the link */
      ret = gst_pad_link (new_pad, audio_sink_pad);
      if (GST_PAD_LINK_FAILED (ret)) {
          g_print ("Type is '%s' but link failed.\n", new_pad_type);
      } else {
          g_print ("Link succeeded (type '%s').\n", new_pad_type);
      }
  }
  else if (g_str_has_prefix (new_pad_type, "video/x-raw")) {
      /* Attempt the link */
      ret = gst_pad_link (new_pad, video_sink_pad);
      if (GST_PAD_LINK_FAILED (ret)) {
          g_print ("Type is '%s' but link failed.\n", new_pad_type);
      } else {
          g_print ("Link succeeded (type '%s').\n", new_pad_type);
      }
  }
  else{
      g_print ("It has type '%s' which is not raw video/audio. Ignoring.\n", new_pad_type);
      goto exit;
  }

exit:
  /* Unreference the new pad's caps, if we got them */
  if (new_pad_caps != NULL)
    gst_caps_unref (new_pad_caps);

  /* Unreference the sink pad */
  gst_object_unref (audio_sink_pad);
  gst_object_unref (video_sink_pad);
}

多加了一个videoconvert和videosink用来显示视频。修改了回调函数,使其支持视频和音频的pad 绑定。

结论

这个例程展示了:

  • 如何使用GSignals获得事件通知;
  • 如何直接连接GstPads而不是它们的父元素。;
  • GStreamer元素的各种状态。
    你还将这些项目结合起来,建立一个动态管道,这个管道不是在程序开始时定义的,而是在有关媒体信息可用时创建的。
    现在你可以继续学习基本教程,在《基本教程4:时间管理》中学习执行
    寻求和时间相关的查询,或者转到播放教程,对播放元素有更深入的了解。
    请记住,在这个页面的附件中,你应该可以找到本教程的完整源代码和构建本教程所需的任何附件文件。很高兴你能来这里,我们很快就会再见面的
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值