FFmepg中文例子—指导2:输出到屏幕

指导2:输出到屏幕

 

SDL和视频

 

为了在屏幕上显示,我们将使用SDL.SDLSimple Direct Layer的缩写。它是一个出色的多媒体库,适用于多平台,并且被用在许多工程中。你可以从它的官方网站的网址 http://www.libsdl.org/上来得到这个库的源代码或者如果有可能的话你可以直接下载开发包到你的操作系统中。按照这个指导,你将需要 编译这个库。(剩下的几个指导中也是一样)

SDL库中有许多种方式来在屏幕上绘制图形,而且它有一个特殊的方式来在屏幕上显示图像――这种方式叫做YUV覆盖。YUV(从技术上来讲并不叫YUV而 是叫做YCbCr)是一种类似于RGB方式的存储原始图像的格式。粗略的讲,Y是亮度分量,UV是色度分量。(这种格式比RGB复杂的多,因为很多的颜 色信息被丢弃了,而且你可以每2Y1U1V)。SDLYUV覆盖使用一组原始的YUV数据并且在屏幕上显示出他们。它可以允许4种不同的 YUV格式,但是其中的YV12是最快的一种。还有一个叫做YUV420PYUV格式,它和YV12是一样的,除了UV分量的位置被调换了以外。 420意味着它以420的比例进行了二次抽样,基本上就意味着1个颜色分量对应着4个亮度分量。所以它的色度信息只有原来的1/4。这是一种节省带宽 的好方式,因为人眼感觉不到这种变化。在名称中的P表示这种格式是平面的――简单的说就是YUV分量分别在不同的数组中。FFMPEG可以把图像格式 转换为YUV420P,但是现在很多视频流的格式已经是YUV420P的了或者可以被很容易的转换成YUV420P格式。

于是,我们现在计划把指导1中的SaveFrame()函数替换掉,让它直接输出我们的帧到屏幕上去。但一开始我们必需要先看一下如何使用SDL库。首先我们必需先包含SDL库的头文件并且初始化它。

 

 1 #include <SDL.h>
2
3 #include <SDL_thread.h>
4
5
6
7 if(SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_TIMER)) {
8
9 fprintf(stderr, "Could not initialize SDL - %s\n", SDL_GetError());
10
11 exit(1);
12
13 }


SDL_Init()函数告诉了SDL库,哪些特性我们将要用到。当然SDL_GetError()是一个用来手工除错的函数。

 

创建一个显示

 

现在我们需要在屏幕上的一个地方放上一些东西。在SDL中显示图像的基本区域叫做surface

 

 1 SDL_Surface *screen;
2
3
4
5 screen = SDL_SetVideoMode(pCodecCtx->width, pCodecCtx->height, 0, 0);
6
7 if(!screen) {
8
9 fprintf(stderr, "SDL: could not set video mode - exiting\n");
10
11 exit(1);
12
13 }


这就创建了一个给定高度和宽度的屏幕。下一个选项是屏幕的颜色深度――0表示使用和当前一样的深度。(这个在OS X系统上不能正常工作,原因请看源代码)

现在我们在屏幕上来创建一个YUV覆盖以便于我们输入视频上去:

 

1 SDL_Overlay     *bmp;
2
3
4
5 bmp = SDL_CreateYUVOverlay(pCodecCtx->width, pCodecCtx->height,
6
7 SDL_YV12_OVERLAY, screen);


正如前面我们所说的,我们使用YV12来显示图像。

 

显示图像

 

前面那些都是很简单的。现在我们需要来显示图像。让我们看一下是如何来处理完成后的帧的。我们将原来对RGB处理的方式,并且替换SaveFrame() 为显示到屏幕上的代码。为了显示到屏幕上,我们将先建立一个AVPicture结构体并且设置其数据指针和行尺寸来为我们的YUV覆盖服务:

 

 1  if(frameFinished) {
2
3 SDL_LockYUVOverlay(bmp);
4
5
6
7 AVPicture pict;
8
9 pict.data[0] = bmp->pixels[0];
10
11 pict.data[1] = bmp->pixels[2];
12
13 pict.data[2] = bmp->pixels[1];
14
15
16
17 pict.linesize[0] = bmp->pitches[0];
18
19 pict.linesize[1] = bmp->pitches[2];
20
21 pict.linesize[2] = bmp->pitches[1];
22
23
24
25 // Convert the image into YUV format that SDL uses
26
27 img_convert(&pict, PIX_FMT_YUV420P,
28
29 (AVPicture *)pFrame, pCodecCtx->pix_fmt,
30
31 pCodecCtx->width, pCodecCtx->height);
32
33
34
35 SDL_UnlockYUVOverlay(bmp);
36
37 }


首先,我们锁定这个覆盖,因为我们将要去改写它。这是一个避免以后发生问题的好习惯。正如前面所示的,这个AVPicture结构体有一个数据指针指向一 个有4个元素的指针数据。由于我们处理的是YUV420P,所以我们只需要3个通道即只要三组数据。其它的格式可能需要第四个指针来表示alpha通道或 者其它参数。行尺寸正如它的名字表示的意义一样。在YUV覆盖中相同功能的结构体是像素pixel和程度pitch。(程度pitch是在SDL里用来表 示指定行数据宽度的值)。所以我们现在做的是让我们的覆盖中的pict.data中的三个指针有一个指向必要的空间的地址。类似的,我们可以直接从覆盖中 得到行尺寸信息。像前面一样我们使用img_convert来把格式转换成PIX_FMT_YUV420P

 

绘制图像

 

但我们仍然需要告诉SDL如何来实际显示我们给的数据。我们也会传递一个表明电影位置、宽度、高度和缩放大小的矩形参数给SDL的函数。这样,SDL为我们做缩放并且它可以通过显卡的帮忙来进行快速缩放。

 

 1 SDL_Rect rect;
2
3
4
5 if(frameFinished) {
6
7
8
9 // Convert the image into YUV format that SDL uses
10
11 img_convert(&pict, PIX_FMT_YUV420P,
12
13 (AVPicture *)pFrame, pCodecCtx->pix_fmt,
14
15 pCodecCtx->width, pCodecCtx->height);
16
17
18
19 SDL_UnlockYUVOverlay(bmp);
20
21 rect.x = 0;
22
23 rect.y = 0;
24
25 rect.w = pCodecCtx->width;
26
27 rect.h = pCodecCtx->height;
28
29 SDL_DisplayYUVOverlay(bmp, &rect);
30
31 }


现在我们的视频显示出来了!

 

让我们再花一点时间来看一下SDL的特性:它的事件驱动系统。SDL被设置成当你在SDL中点击或者移动鼠标或者向它发送一个信号它都将产生一个事件的驱 动方式。如果你的程序想要处理用户输入的话,它就会检测这些事件。你的程序也可以产生事件并且传递给SDL事件系统。当使用SDL进行多线程编程的时候, 这相当有用,这方面代码我们可以在指导4中看到。在这个程序中,我们将在处理完包以后就立即轮询事件。现在而言,我们将处理SDL_QUIT事件以便于我 们退出:

 

 1 SDL_Event       event;
2
3
4
5 av_free_packet(&packet);
6
7 SDL_PollEvent(&event);
8
9 switch(event.type) {
10
11 case SDL_QUIT:
12
13 SDL_Quit();
14
15 exit(0);
16
17 break;
18
19 default:
20
21 break;
22
23 }


让我们去掉旧的冗余代码,开始编译。如果你使用的是Linux或者其变体,使用SDL库进行编译的最好方式为:

gcc -o tutorial02 tutorial02.c -lavutil -lavformat -lavcodec -lz -lm \

`sdl-config --cflags --libs`

这里的sdl-config命令会打印出用于gcc编译的包含正确SDL库的适当参数。为了进行编译,在你自己的平台你可能需要做的有点不同:请查阅一下SDL文档中关于你的系统的那部分。一旦可以编译,就马上运行它。

 

当运行这个程序的时候会发生什么呢?电影简直跑疯了!实际上,我们只是以我们能从文件中解码帧的最快速度显示了所有的电影的帧。现在我们没有任何代码来计 算出我们什么时候需要显示电影的帧。最后(在指导5),我们将花足够的时间来探讨同步问题。但一开始我们会先忽略这个,因为我们有更加重要的事情要处理: 音频!

 1   } else {
2
3 break;
4
5 }
6
7 }
8
9 // Is this a packet from the video stream?
10
11 if(packet->stream_index == is->videoStream) {
12
13 packet_queue_put(&is->videoq, packet);
14
15 } else if(packet->stream_index == is->audioStream) {
16
17 packet_queue_put(&is->audioq, packet);
18
19 } else {
20
21 av_free_packet(packet);
22
23 }
24
25 }

 

这里没有什么新东西,除了我们给音频和视频队列限定了一个最大值并且我们添加一个检测读错误的函数。格式上下文里面有一个叫做pb的 ByteIOContext类型结构体。这个结构体是用来保存一些低级的文件信息。函数url_ferror用来检测结构体并发现是否有些读取文件错误。

在循环以后,我们的代码是用等待其余的程序结束和提示我们已经结束的。这些代码是有益的,因为它指示出了如何驱动事件--后面我们将显示影像。

 

 1   while(!is->quit) {
2
3 SDL_Delay(100);
4
5 }
6
7
8
9 fail:
10
11 if(1){
12
13 SDL_Event event;
14
15 event.type = FF_QUIT_EVENT;
16
17 event.user.data1 = is;
18
19 SDL_PushEvent(&event);
20
21 }
22
23 return 0;


我们使用SDL常量SDL_USEREVENT来从用户事件中得到值。第一个用户事件的值应当是SDL_USEREVENT,下一个是 SDL_USEREVENT1并且依此类推。在我们的程序中FF_QUIT_EVENT被定义成SDL_USEREVENT2。如果喜欢,我们也可以 传递用户数据,在这里我们传递的是大结构体的指针。最后我们调用SDL_PushEvent()函数。在我们的事件分支中,我们只是像以前放入 SDL_QUIT_EVENT部分一样。我们将在自己的事件队列中详细讨论,现在只是确保我们正确放入了FF_QUIT_EVENT事件,我们将在后面捕 捉到它并且设置我们的退出标志quit

 

得到帧:video_thread

 

当我们准备好解码器后,我们开始视频线程。这个线程从视频队列中读取包,把它解码成视频帧,然后调用queue_picture函数把处理好的帧放入到图片队列中:

 

 1 int video_thread(void *arg) {
2
3 VideoState *is = (VideoState *)arg;
4
5 AVPacket pkt1, *packet = &pkt1;
6
7 int len1, frameFinished;
8
9 AVFrame *pFrame;
10
11
12
13 pFrame = avcodec_alloc_frame();
14
15
16
17 for(;;) {
18
19 if(packet_queue_get(&is->videoq, packet, 1) < 0) {
20
21 // means we quit getting packets
22
23 break;
24
25 }
26
27 // Decode video frame
28
29 len1 = avcodec_decode_video(is->video_st->codec, pFrame, &frameFinished,
30
31 packet->data, packet->size);
32
33
34
35 // Did we get a video frame?
36
37 if(frameFinished) {
38
39 if(queue_picture(is, pFrame) < 0) {
40
41 break;
42
43 }
44
45 }
46
47 av_free_packet(packet);
48
49 }
50
51 av_free(pFrame);
52
53 return 0;
54
55 }


在这里的很多函数应该很熟悉吧。我们把avcodec_decode_video函数移到了这里,替换了一些参数,例如:我们把AVStream保存在我 们自己的大结构体中,所以我们可以从那里得到编解码器的信息。我们仅仅是不断的从视频队列中取包一直到有人告诉我们要停止或者出错为止。

 

把帧队列化

 

让我们看一下保存解码后的帧pFrame到图像队列中去的函数。因为我们的图像队列是SDL的覆盖的集合(基本上不用让视频显示函数再做计算了),我们需要把帧转换成相应的格式。我们保存到图像队列中的数据是我们自己做的一个结构体。

 

1 typedef struct VideoPicture {
2
3 SDL_Overlay *bmp;
4
5 int width, height;
6
7 int allocated;
8
9 } VideoPicture;


我们的大结构体有一个可以保存这些缓冲区。然而,我们需要自己来申请SDL_Overlay(注意:allocated标志会指明我们是否已经做了这个申请的动作与否)。

为了使用这个队列,我们有两个指针--写入指针和读取指针。我们也要保证一定数量的实际数据在缓冲中。要写入到队列中,我们先要等待缓冲清空以便于有位置 来保存我们的VideoPicture。然后我们检查看我们是否已经申请到了一个可以写入覆盖的索引号。如果没有,我们要申请一段空间。我们也要重新申请 缓冲如果窗口的大小已经改变。然而,为了避免被锁定,尽是避免在这里申请(我现在还不太清楚原因;我相信是为了避免在其它线程中调用SDL覆盖函数的原 因)。

 

 1 int queue_picture(VideoState *is, AVFrame *pFrame) {
2
3
4
5 VideoPicture *vp;
6
7 int dst_pix_fmt;
8
9 AVPicture pict;
10
11
12
13
14
15 SDL_LockMutex(is->pictq_mutex);
16
17 while(is->pictq_size >= VIDEO_PICTURE_QUEUE_SIZE &&
18
19 !is->quit) {
20
21 SDL_CondWait(is->pictq_cond, is->pictq_mutex);
22
23 }
24
25 SDL_UnlockMutex(is->pictq_mutex);
26
27
28
29 if(is->quit)
30
31 return -1;
32
33
34
35 // windex is set to 0 initially
36
37 vp = &is->pictq[is->pictq_windex];
38
39
40
41
42
43 if(!vp->bmp ||
44
45 vp->width != is->video_st->codec->width ||
46
47 vp->height != is->video_st->codec->height) {
48
49 SDL_Event event;
50
51
52
53 vp->allocated = 0;
54
55
56
57 event.type = FF_ALLOC_EVENT;
58
59 event.user.data1 = is;
60
61 SDL_PushEvent(&event);
62
63
64
65
66
67 SDL_LockMutex(is->pictq_mutex);
68
69 while(!vp->allocated && !is->quit) {
70
71 SDL_CondWait(is->pictq_cond, is->pictq_mutex);
72
73 }
74
75 SDL_UnlockMutex(is->pictq_mutex);
76
77 if(is->quit) {
78
79 return -1;
80
81 }
82
83 }


这里的事件机制与前面我们想要退出的时候看到的一样。我们已经定义了事件FF_ALLOC_EVENT作为SDL_USEREVENT。我们把事件发到事件队列中然后等待申请内存的函数设置好条件变量。

让我们来看一看如何来修改事件循环:

 

 1 for(;;) {
2
3 SDL_WaitEvent(&event);
4
5 switch(event.type) {
6
7
8
9 case FF_ALLOC_EVENT:
10
11 alloc_picture(event.user.data1);
12
13 break;


记住event.user.data1是我们的大结构体。就这么简单。让我们看一下alloc_picture()函数:

 

 1 void alloc_picture(void *userdata) {
2
3
4
5 VideoState *is = (VideoState *)userdata;
6
7 VideoPicture *vp;
8
9
10
11 vp = &is->pictq[is->pictq_windex];
12
13 if(vp->bmp) {
14
15 // we already have one make another, bigger/smaller
16
17 SDL_FreeYUVOverlay(vp->bmp);
18
19 }
20
21 // Allocate a place to put our YUV image on that screen
22
23 vp->bmp = SDL_CreateYUVOverlay(is->video_st->codec->width,
24
25 is->video_st->codec->height,
26
27 SDL_YV12_OVERLAY,
28
29 screen);
30
31 vp->width = is->video_st->codec->width;
32
33 vp->height = is->video_st->codec->height;
34
35
36
37 SDL_LockMutex(is->pictq_mutex);
38
39 vp->allocated = 1;
40
41 SDL_CondSignal(is->pictq_cond);
42
43 SDL_UnlockMutex(is->pictq_mutex);
44
45 }


你可以看到我们把SDL_CreateYUVOverlay函数从主循环中移到了这里。这段代码应该完全可以自我注释。记住我们把高度和宽度保存到VideoPicture结构体中因为我们需要保存我们的视频的大小没有因为某些原因而改变。

好,我们几乎已经全部解决并且可以申请到YUV覆盖和准备好接收图像。让我们回顾一下queue_picture并看一个拷贝帧到覆盖的代码。你应该能认出其中的一部分:

 

 1 int queue_picture(VideoState *is, AVFrame *pFrame) {
2
3 if(vp->bmp) {
4
5 SDL_LockYUVOverlay(vp->bmp);
6
7 dst_pix_fmt = PIX_FMT_YUV420P;
8
9 pict.data[0] = vp->bmp->pixels[0];
10
11 pict.data[1] = vp->bmp->pixels[2];
12
13 pict.data[2] = vp->bmp->pixels[1];
14
15
16
17 pict.linesize[0] = vp->bmp->pitches[0];
18
19 pict.linesize[1] = vp->bmp->pitches[2];
20
21 pict.linesize[2] = vp->bmp->pitches[1];
22
23
24
25 // Convert the image into YUV format that SDL uses
26
27 img_convert(&pict, dst_pix_fmt,
28
29 (AVPicture *)pFrame, is->video_st->codec->pix_fmt,
30
31 is->video_st->codec->width, is->video_st->codec->height);
32
33
34
35 SDL_UnlockYUVOverlay(vp->bmp);
36
37
38
39 if(++is->pictq_windex == VIDEO_PICTURE_QUEUE_SIZE) {
40
41 is->pictq_windex = 0;
42
43 }
44
45 SDL_LockMutex(is->pictq_mutex);
46
47 is->pictq_size++;
48
49 SDL_UnlockMutex(is->pictq_mutex);
50
51 }
52
53 return 0;
54
55 }


这部分代码和前面用到的一样,主要是简单的用我们的帧来填充YUV覆盖。最后一点只是简单的给队列加1。这个队列在写的时候会一直写入到满为止,在读的时 候会一直读空为止。因此所有的都依赖于is->pictq_size值,这要求我们必需要锁定它。这里我们做的是增加写指针(在必要的时候采用轮转 的方式),然后锁定队列并且增加尺寸。现在我们的读者函数将会知道队列中有了更多的信息,当队列满的时候,我们的写入函数也会知道。

 

显示视频

 

这就是我们的视频线程。现在我们看过了几乎所有的线程除了一个--记得我们调用schedule_refresh()函数吗?让我们看一下实际中是如何做的:

 

1 static void schedule_refresh(VideoState *is, int delay) {
2
3 SDL_AddTimer(delay, sdl_refresh_timer_cb, is);
4
5 }


函数SDL_AddTimer()SDL中的一个定时(特定的毫秒)执行用户定义的回调函数(可以带一些参数user data)的简单函数。我们将用这个函数来定时刷新视频--每次我们调用这个函数的时候,它将设置一个定时器来触发定时事件来把一帧从图像队列中显示到屏幕上。

但是,让我们先触发那个事件。

 

 1 static Uint32 sdl_refresh_timer_cb(Uint32 interval, void *opaque) {
2
3 SDL_Event event;
4
5 event.type = FF_REFRESH_EVENT;
6
7 event.user.data1 = opaque;
8
9 SDL_PushEvent(&event);
10
11 return 0;
12
13 }


这里向队列中写入了一个现在很熟悉的事件。FF_REFRESH_EVENT被定义成SDL_USEREVENT+1。要注意的一件事是当返回0的时候,SDL停止定时器,于是回调就不会再发生。

现在我们产生了一个FF_REFRESH_EVENT事件,我们需要在事件循环中处理它:

 

 1 for(;;) {
2
3
4
5 SDL_WaitEvent(&event);
6
7 switch(event.type) {
8
9
10
11 case FF_REFRESH_EVENT:
12
13 video_refresh_timer(event.user.data1);
14
15 break;


于是我们就运行到了这个函数,在这个函数中会把数据从图像队列中取出:

 

 1 void video_refresh_timer(void *userdata) {
2
3
4
5 VideoState *is = (VideoState *)userdata;
6
7 VideoPicture *vp;
8
9
10
11 if(is->video_st) {
12
13 if(is->pictq_size == 0) {
14
15 schedule_refresh(is, 1);
16
17 } else {
18
19 vp = &is->pictq[is->pictq_rindex];
20
21
22
23
24
25 schedule_refresh(is, 80);
26
27
28
29
30
31 video_display(is);
32
33
34
35
36
37 if(++is->pictq_rindex == VIDEO_PICTURE_QUEUE_SIZE) {
38
39 is->pictq_rindex = 0;
40
41 }
42
43 SDL_LockMutex(is->pictq_mutex);
44
45 is->pictq_size--;
46
47 SDL_CondSignal(is->pictq_cond);
48
49 SDL_UnlockMutex(is->pictq_mutex);
50
51 }
52
53 } else {
54
55 schedule_refresh(is, 100);
56
57 }
58
59 }


现在,这只是一个极其简单的函数:当队列中有数据的时候,他从其中获得数据,为下一帧设置定时器,调用video_display函数来真正显示图像到屏 幕上,然后把队列读索引值加1,并且把队列的尺寸size1。你可能会注意到在这个函数中我们并没有真正对vp做一些实际的动作,原因是这样的:我们将 在后面处理。我们将在后面同步音频和视频的时候用它来访问时间信息。你会在这里看到这个注释信息“timing密码here”。那里我们将讨论什么时候显 示下一帧视频,然后把相应的值写入到schedule_refresh()函数中。现在我们只是随便写入一个值80。从技术上来讲,你可以猜测并验证这个 值,并且为每个电影重新编译程序,但是:1)过一段时间它会漂移;2)这种方式是很笨的。我们将在后面来讨论它。

我们几乎做完了;我们仅仅剩了最后一件事:显示视频!下面就是video_display函数:

 

 1 void video_display(VideoState *is) {
2
3
4
5 SDL_Rect rect;
6
7 VideoPicture *vp;
8
9 AVPicture pict;
10
11 float aspect_ratio;
12
13 int w, h, x, y;
14
15 int i;
16
17
18
19 vp = &is->pictq[is->pictq_rindex];
20
21 if(vp->bmp) {
22
23 if(is->video_st->codec->sample_aspect_ratio.num == 0) {
24
25 aspect_ratio = 0;
26
27 } else {
28
29 aspect_ratio = av_q2d(is->video_st->codec->sample_aspect_ratio) *
30
31 is->video_st->codec->width / is->video_st->codec->height;
32
33 }
34
35 if(aspect_ratio <= 0.0) {
36
37 aspect_ratio = (float)is->video_st->codec->width /
38
39 (float)is->video_st->codec->height;
40
41 }
42
43 h = screen->h;
44
45 w = ((int)rint(h * aspect_ratio)) & -3;
46
47 if(w > screen->w) {
48
49 w = screen->w;
50
51 h = ((int)rint(w / aspect_ratio)) & -3;
52
53 }
54
55 x = (screen->w - w) / 2;
56
57 y = (screen->h - h) / 2;
58
59
60
61 rect.x = x;
62
63 rect.y = y;
64
65 rect.w = w;
66
67 rect.h = h;
68
69 SDL_DisplayYUVOverlay(vp->bmp, &rect);
70
71 }
72
73 }


因为我们的屏幕可以是任意尺寸(我们设置为640x480并且用户可以自己来改变尺寸),我们需要动态计算出我们显示的图像的矩形大小。所以一开始我们需 要计算出电影的纵横比aspect ratio,表示方式为宽度除以高度。某些编解码器会有奇数采样纵横比,只是简单表示了一个像素或者一个采样的宽度除以高度的比例。因为宽度和高度在我们 的编解码器中是用像素为单位的,所以实际的纵横比与纵横比乘以样本纵横比相同。某些编解码器会显示纵横比为0,这表示每个像素的纵横比为1x1。然后我们 把电影缩放到适合屏幕的尽可能大的尺寸。这里的& -3表示与-3做与运算,实际上是让它们4字节对齐。然后我们把电影移到中心位置,接着调用SDL_DisplayYUVOverlay()函数。

结果是什么?我们做完了吗?嗯,我们仍然要重新改写声音部分的代码来使用新的VideoStruct结构体,但是那些只是尝试着改变,你可以看一下那些参考示例代码。最后我们要做的是改变ffmpeg提供的默认退出回调函数为我们的退出回调函数。

 

1 VideoState *global_video_state;
2
3
4
5 int decode_interrupt_cb(void) {
6
7 return (global_video_state && global_video_state->quit);
8
9 }


我们在主函数中为大结构体设置了global_video_state

这就是了!让我们编译它:

gcc -o tutorial04 tutorial04.c -lavutil -lavformat -lavcodec -lz -lm \

`sdl-config --cflags --libs`

请享受一下没有经过同步的电影!下次我们将编译一个可以最终工作的电影播放器。

转载于:https://www.cnblogs.com/brucehou/archive/2011/10/20/2219308.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值