【FFMPEG】FFplay音视频同步分析(下)

ops/2024/11/14 4:53:12/

audio_decode_frame函数分析

首先说明一下,audio_decode_frame() 函数跟解码毫无关系,真正的解码函数是 decoder_decode_frame 。

audio_decode_frame() 函数的主要作用是从 FrameQueue 队列里面读取 AVFrame ,然后把 is->audio_buf 指向 AVFrame 的 data。如果 AVFrame 的音频信息跟 is->audio_src 不一致,就会进行重采样。如果进行重采样, is->audio_buf 指针会指向重采样后的内存 audio_buf1,而不是 AVFrame::data。

audio_decode_frame() 函数的流程图如下:
在这里插入图片描述

audio_decode_frame() 函数的重点代码如下:

/*** Decode one audio frame and return its uncompressed size.** The processed audio frame is decoded, converted if required, and* stored in is->audio_buf, with size in bytes given by the return* value.*/
static int audio_decode_frame(VideoState *is)
{int data_size, resampled_data_size;av_unused double audio_clock0;int wanted_nb_samples;Frame *af;if (is->paused)return -1;do {
...if (!(af = frame_queue_peek_readable(&is->sampq)))---------提取队列的AVFramereturn -1;frame_queue_next(&is->sampq);} while (af->serial != is->audioq.serial);

从上面代码可以看到,在提取 FrameQueue 队列的 AVFrame 的时候,会对 32 位的系统做一个处理。这种情况在一些性能差的设备会出现,就是解码线程慢于播放线程,播放线程来取数据的时候,发现队列没有数据可读,这时候,可以最多等等 1/2 的回调时间,在本文的命令里,是 0.04s 执行一次回调函数。

回调间隔的计算方式如下:

回调间隔 = 1000000LL * is->audio_hw_buf_size / is->audio_tgt.bytes_per_sec
上图 除以 2 ,就是 二分之一 的回调间隔。所以在一些老旧设备上,audio_decode_frame() 函数最多会 av_usleep 休眠 0.02s 才返回 -1 ,也就是拿不到数据。

休眠 1/2 的回调时间有什么好处呢?

举个例子,播放的还是 juren-5s.mp4 ,每 0.04s 秒调一次 audio_decode_frame() 函数。

在下午2点(14:00)的时候,SDL 回调了 audio_decode_frame() 函数,因为设备性能差,此刻 FrameQueue 里面没有数据可以拿。14:00:005 的时候才解码出数据放进去 FrameQueue ,解码慢了0.005s。如果 不sleep,立即就返回了 -1。因为下次回调要等 0.04s,返回 -1 就会导致音频设备多播放了 0.04s 的静音数据。也就是说本来应该播放的数据,如果因为解码慢,又不sleep,延迟了0.04s才播放。

如果 进行 av_sleep 就可以在 14:00:005 左右的时刻把数据写进 SDL 的内存,只延迟了0.005s。

提示:av_usleep() 函数的单位是微妙,也就是百万分之一 秒。


拿到 AVFrame 之后,就会计算 AVFrame 里面音频数据的大小(data_size),然后校验一下 声道数 与 声道布局是否一致,最后就是判断是否需要重采样,如下:

    data_size = av_samples_get_buffer_size(NULL, af->frame->ch_layout.nb_channels,af->frame->nb_samples,af->frame->format, 1);----------------计算AVFrame的大小wanted_nb_samples = synchronize_audio(is, af->frame->nb_samples);if (af->frame->format        != is->audio_src.fmt            ||av_channel_layout_compare(&af->frame->ch_layout, &is->audio_src.ch_layout) ||af->frame->sample_rate   != is->audio_src.freq           ||(wanted_nb_samples       != af->frame->nb_samples && !is->swr_ctx)) {swr_free(&is->swr_ctx);---------------------------判断是否需要重采样swr_alloc_set_opts2(&is->swr_ctx,&is->audio_tgt.ch_layout, is->audio_tgt.fmt, is->audio_tgt.freq,&af->frame->ch_layout, af->frame->format, af->frame->sample_rate,0, NULL);if (!is->swr_ctx || swr_init(is->swr_ctx) < 0) {av_log(NULL, AV_LOG_ERROR,"Cannot create sample rate converter for conversion of %d Hz %s %d channels to %d Hz %s %d channels!\n",af->frame->sample_rate, av_get_sample_fmt_name(af->frame->format), af->frame->ch_layout.nb_channels,is->audio_tgt.freq, av_get_sample_fmt_name(is->audio_tgt.fmt), is->audio_tgt.ch_layout.nb_channels);swr_free(&is->swr_ctx);return -1;}if (av_channel_layout_copy(&is->audio_src.ch_layout, &af->frame->ch_layout) < 0)return -1;is->audio_src.freq = af->frame->sample_rate;is->audio_src.fmt = af->frame->format;}

判断是否需要重采样的逻辑是异常复杂的,在分析之前,需要讲解一下 is->audio_src 这个变量的赋值过程,如下:

/* open a given stream. Return 0 if OK */
static int stream_component_open(VideoState *is, int stream_index)
{......
#if CONFIG_AVFILTER{AVFilterContext *sink;is->audio_filter_src.freq           = avctx->sample_rate;ret = av_channel_layout_copy(&is->audio_filter_src.ch_layout, &avctx->ch_layout);if (ret < 0)goto fail;is->audio_filter_src.fmt            = avctx->sample_fmt;if ((ret = configure_audio_filters(is, afilters, 0)) < 0)goto fail;sink = is->out_audio_filter;sample_rate    = av_buffersink_get_sample_rate(sink);ret = av_buffersink_get_ch_layout(sink, &ch_layout);if (ret < 0)goto fail;}
#elsesample_rate    = avctx->sample_rate;ret = av_channel_layout_copy(&ch_layout, &avctx->ch_layout);if (ret < 0)goto fail;
#endif/* prepare audio output */if ((ret = audio_open(is, &ch_layout, sample_rate, &is->audio_tgt)) < 0)-----------goto fail;is->audio_hw_buf_size = ret;is->audio_src = is->audio_tgt;----------------is->audio_buf_size  = 0;is->audio_buf_index = 0;

从上面代码可以看到,编译的时候启不启用 CONFIG_AVFILTER(滤镜模块),sample_rate,nb_channels,channel_layout 变量的赋值会不一样。

不启用滤镜模块,sample_rate 等变量就会从解码器实例赋值过来的,因此FrameQueue 存储的是从解码器出来的数据。

启用了滤镜模块,sample_rate 等变量就会从出口滤镜里提取,因此FrameQueue 存储的是从 buffersink 出口滤镜出来的数据。

然后就会尝试用 sample_rate,nb_channels,channel_layout 去打开音频硬件设备。

但是音频设备不一定支持这些采样率跟声道布局,所以可能会做下调整,调整后的格式就放在 audio_tgt 变量里面。

不过基本不会有那么差的音响硬件,大部分情况,audio_tgt 的格式就是跟 sample_rate等变量一样的,一般播放不会进行降低采样率或者调整声道布局。

最后还把 audio_tgt 赋值给 audio_src。


回到 audio_decode_frame() 函数判断是否需要重采样的代码。

if ( af->frame->format        != is->audio_src.fmt               ||dec_channel_layout       != is->audio_src.channel_layout ||af->frame->sample_rate   != is->audio_src.freq           ||(wanted_nb_samples       != af->frame->nb_samples && !is->swr_ctx) ) {...创建重采样实例...
}

注意:上面代码中的 is->audio_src->fmt 音频采样格式是写死成 AV_SAMPLE_FMT_S16 的,所有的数据都会转成 AV_SAMPLE_FMT_S16 再丢给 SDL。

所以一共会有以下 3 种场景需要进行重采样。

1,从 FrameQueue 拿到的 AVFrame 的音频 采样格式 format 不等于 AV_SAMPLE_FMT_S16。

format 不等于 AV_SAMPLE_FMT_S16 有两种情况,一是 MP4 里面音频流的采样格式本身就不是 AV_SAMPLE_FMT_S16,二是 ffpaly 命令行参数使用了滤镜参数改变了 format 采样格式,所以导致 FrameQueue 队列存储的音频采样格式就不是 AV_SAMPLE_FMT_S16 。

2,audio_open() 打开音频硬件设备的时候,对声道布局,采样率进行过调整,导致 is->audio_src 与 af 的采样率,声道布局不一致。

3,用视频时钟为主时钟进行音视频同步,当音视频不同步的时候,就需要减少或增加音频帧的样本数量,让音频流能拉长或者缩短,达到音频流能追赶视频流 或者减速慢下来等待视频流追上来 的效果。也就是最后一个条件, wanted_nb_samples 不等于 af->frame->nb_samples

第三种场景可以不用管,因为基本没人用视频时钟来做同步。


当需要进行重采样的时候,就会创建重采样实例 is->swr_ctx,然后进行重采样操作,如下:

static int audio_decode_frame(VideoState *is)
{
......if (is->swr_ctx) {--------------const uint8_t **in = (const uint8_t **)af->frame->extended_data;uint8_t **out = &is->audio_buf1;int out_count = (int64_t)wanted_nb_samples * is->audio_tgt.freq / af->frame->sample_rate + 256;int out_size  = av_samples_get_buffer_size(NULL, is->audio_tgt.ch_layout.nb_channels, out_count, is->audio_tgt.fmt, 0);int len2;if (out_size < 0) {av_log(NULL, AV_LOG_ERROR, "av_samples_get_buffer_size() failed\n");return -1;}if (wanted_nb_samples != af->frame->nb_samples) {if (swr_set_compensation(is->swr_ctx, (wanted_nb_samples - af->frame->nb_samples) * is->audio_tgt.freq / af->frame->sample_rate,wanted_nb_samples * is->audio_tgt.freq / af->frame->sample_rate) < 0) {av_log(NULL, AV_LOG_ERROR, "swr_set_compensation() failed\n");return -1;}}av_fast_malloc(&is->audio_buf1, &is->audio_buf1_size, out_size);if (!is->audio_buf1)return AVERROR(ENOMEM);len2 = swr_convert(is->swr_ctx, out, out_count, in, af->frame->nb_samples);------------重采样if (len2 < 0) {av_log(NULL, AV_LOG_ERROR, "swr_convert() failed\n");return -1;}if (len2 == out_count) {av_log(NULL, AV_LOG_WARNING, "audio buffer is probably too small\n");if (swr_init(is->swr_ctx) < 0)swr_free(&is->swr_ctx);}is->audio_buf = is->audio_buf1;----------------设置resampled_data_size = len2 * is->audio_tgt.ch_layout.nb_channels * av_get_bytes_per_sample(is->audio_tgt.fmt);} else {is->audio_buf = af->frame->data[0];--------------设置指针resampled_data_size = data_size;}

上面代码中的重点就是 进行 与 不进行重采样,audio_buf 指针指向的地址是不一样的。

不进行重采样,audio_buf 指针指向 af->frame->data[0]。

进行了重采样,audio_buf 指针指向 is->audio_buf1,audio_buf1 可以说是重采样之后的内存。

提示:resampled_data_size 变量也会相应变化。
最后,就是设置 is->audio_clock,如下:

    audio_clock0 = is->audio_clock;/* update the audio clock with the pts */if (!isnan(af->pts))is->audio_clock = af->pts + (double) af->frame->nb_samples / af->frame->sample_rate;elseis->audio_clock = NAN;is->audio_clock_serial = af->serial;

is->audio_clock 代表播放完这一帧数据后,音频流的 pts 是多少。这是用来计算音频流当前的 pts 的,如下:

在这里插入图片描述

至此,audio_decode_frame() 函数分析完毕。

video_thread视频解码线程分析

之前在 stream_component_open() 里面的 decode_start() 函数开启了 video_thread 线程,如下:

    case AVMEDIA_TYPE_VIDEO:is->video_stream = stream_index;is->video_st = ic->streams[stream_index];if ((ret = decoder_init(&is->viddec, avctx, &is->videoq, is->continue_read_thread)) < 0)goto fail;if ((ret = decoder_start(&is->viddec, video_thread, "video_decoder", is)) < 0)----------------video_threadgoto out;is->queue_attachments_req = 1;break;

video_thread 线程主要是负责 解码 PacketQueue 队列里面的 AVPacket 的,解码出来 AVFrame,然后丢给入口滤镜,再从出口滤镜AVFrame 读出来,再插入 FrameQueue 队列。流程图如下:

在这里插入图片描述

video_thread() 函数里面有几个 CONFIG_AVFILTER 的宏判断,这是判断编译的时候是否启用滤镜模块。默认都是启用滤镜模块的。

下面来分析一下 video_thread() 函数的重点逻辑,如下:

#if CONFIG_AVFILTERAVFilterGraph *graph = NULL;AVFilterContext *filt_out = NULL, *filt_in = NULL;int last_w = 0;-------------0int last_h = 0;-------------0enum AVPixelFormat last_format = -2;int last_serial = -1;int last_vfilter_idx = 0;
#endifif (!frame)return AVERROR(ENOMEM);for (;;) {ret = get_video_frame(is, frame);--------------从解码器读取数据if (ret < 0)goto the_end;if (!ret)continue;#if CONFIG_AVFILTERif (   last_w != frame->width------------------------------start|| last_h != frame->height|| last_format != frame->format|| last_serial != is->viddec.pkt_serial|| last_vfilter_idx != is->vfilter_idx) {--------------------end,重新创建filter

video_thread() 函数里面比较重要的局部变量如下:

1,AVFilterGraph *graph,滤镜容器

2,AVFilterContext *filt_in,入口滤镜指针,指向滤镜容器的输入

3,AVFilterContext *filt_out,出口滤镜指针,指向滤镜容器的输出

4,int last_w ,上一次解码出来的 AVFrame 的宽度,初始值为 0

5,int last_h ,上一次解码出来的 AVFrame 的高度,初始值为 0

6,enum AVPixelFormat last_format ,上一次解码出来的 AVFrame 的像素格式,初始值为 -2

7,int last_serial,上一次解码出来的 AVFrame 的序列号,初始值为 -1

8,int last_vfilter_idx,上一次使用的视频滤镜的索引,ffplay 播放器的命令行是可以指定多个视频滤镜,然后按 w 键切换查看效果的

声明初始化完一些局部变量之后,video_thread() 线程就会进入 for 死循环不断处理任务。

get_video_frame() 函数主要是从解码器读取 AVFrame,里面有一个视频同步的逻辑,同步的逻辑稍微复杂。


需要注意的是,last_w 一开始是赋值为 0 的,所以必然不等于解码出来的 frame->width,所以一开始肯定是会调进入那个 if 判断,然后调 configure_video_filters() 函数创建滤镜。

总结一下,释放旧滤镜,重新创建新的滤镜有3种情况:

1,后面解码出来的 AVFrame 如果跟上一个 AVFrame 的宽高或者格式不一致。

2,按了 w 键,last_vfilter_idx != is->vfilter_idx。ffplay 播放器的命令行是可以指定多个视频滤镜,然后按 w 键切换查看效果的。

3,进行了快进快退操作,因为快进快退会导致 is->viddec.pkt_serial 递增。

这3种情况,ffplay 都会处理,只要解码出来的 AVFrame 跟之前的格式不一致,都会重建滤镜,然后更新 last_xxx 变量,这样滤镜处理才不会出错。

由于每次读取出口滤镜的数据,都会用 while 循环把缓存刷完,不会留数据在滤镜容器里面,所以重建滤镜不会导致数据丢失。


video_thread 线程的逻辑比较简单,复杂的地方都封装在它调用的子函数里面,所以本文简单讲解一下,video_thread() 里面调用的各个函数的作用。

1,get_video_frame(),实际上就是对 decoder_decode_frame() 函数进行了封装,加入了视频同步逻辑。返回值如下:

返回 1,获取到 AVFrame 。
返回 0 ,获取不到 AVFrame 。有3种情况会获取不到 AVFrame,一是MP4文件播放完毕,二是解码速度太慢无数据可读,三是视频比音频播放慢了导致丢帧。
返回 -1,代表 PacketQueue 队列关闭了(abort_request)。返回 -1 会导致 video_thread() 线程用 goto the_end 跳出 for(;😉 循环,跳出循环之后,video_thread 线程就会自己结束了。返回 -1 通常是因为关闭了 ffplay 播放器。

2,configure_video_filters(),创建视频滤镜函数。

3,av_buffersrc_add_frame(),往入口滤镜发送 AVFrame。

4,av_buffersink_get_frame_flags(),从出口滤镜读取 AVFrame。

5,queue_picture(),此函数可能会阻塞。只是对 frame_queue_peek_writable() 跟 frame_queue_push() 两个函数进行了封装。

在 audio_thread() 音频线程里面是用 frame_queue_peek_writable() 跟 frame_queue_push() 两个函数来插入 FrameQueue 队列的。

在 video_thread() 视频线程里面是用 queue_picture() 函数来插入 FrameQueue 队列的。

video_refresh视频播放线程分析

视频播放线程就是 main 主线程,对于 FFplay 播放器,就是在 主线程 里面播放视频流的,如下:

/* handle an event sent by the GUI */
static void event_loop(VideoState *cur_stream)
{SDL_Event event;double incr, pos, frac;for (;;) {double x;refresh_loop_wait_event(cur_stream, &event);-----不断播放视频流画面,直至键盘事件触发switch (event.type) {......}--------------处理键盘事件}
}

如上代码所示,event_loop() 会不断用 refresh_loop_wait_event() 函数检测是否有键盘事件发生,如果有键盘事件发生, refresh_loop_wait_event() 就会返回,然后跑到 switch{event.type}{…} 来处理键盘事件。

如果没有键盘事件发生, refresh_loop_wait_event() 就不会返回,只会不断循环,不断去播放视频流的画面。如下:

static void refresh_loop_wait_event(VideoState *is, SDL_Event *event) {double remaining_time = 0.0;SDL_PumpEvents();while (!SDL_PeepEvents(event, 1, SDL_GETEVENT, SDL_FIRSTEVENT, SDL_LASTEVENT)) {---------检查是否有键盘事件发生if (!cursor_hidden && av_gettime_relative() - cursor_last_shown > CURSOR_HIDE_DELAY) {SDL_ShowCursor(0);cursor_hidden = 1;}if (remaining_time > 0.0)av_usleep((int64_t)(remaining_time * 1000000.0));--------休眠避免执行太多次循环remaining_time = REFRESH_RATE;if (is->show_mode != SHOW_MODE_NONE && (!is->paused || is->force_refresh))video_refresh(is, &remaining_time);-----------无事件发送,播放视频流SDL_PumpEvents();}
}

refresh_loop_wait_event() 函数里面的重点是 remaining_time 变量,这个变量是什么意思呢?

remaining_time 变量的默认值是 0.01(REFRESH_RATE),在 video_refresh() 里面可能会改变 remaining_time 的值,如下:

if (time < is->frame_timer + delay) {*remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);goto display;
}

上面的代码中计算出来的 remaining_time 代表要播放下一帧视频,还需要等待多少秒。可以看到用了 FFMIN 取最小值。

所以remaining_time 变量的含义是,要播放下一帧视频,还需要等待多少秒。或者说要过多久才去检查一下是否可以播放下一帧。

上图中有个 av_usleep(remaining_time) ,就是为了避免 while 循环,过于频繁去检查下一帧是否可以播放。

举个例子:

如果视频流的帧率是24帧每秒,也就是每隔0.04秒播放一帧数据,那 video_refresh() 里面大部分情况会把 remaining_time 赋值为 0.01,每隔 0.01s 去检查下一帧是否可以播放了。

如果视频流的帧率是200帧每秒,也就是每隔0.005秒播放一帧数据,那 video_refresh() 里面大部分情况会把 remaining_time 赋值为 0.005,0.005 实际上就是播放下一帧视频,还需要等待多长时间。

真正播放视频流的函数是 video_refresh() 函数,流程图如下,绿色是默认不会执行的逻辑,不用关注。

在这里插入图片描述

从代码的角度看, video_refresh() 总共只有 4 段逻辑,如下图:

/* called to display each frame */
static void video_refresh(void *opaque, double *remaining_time)
{VideoState *is = opaque;double time;Frame *sp, *sp2;if (!is->paused && get_master_sync_type(is) == AV_SYNC_EXTERNAL_CLOCK && is->realtime)check_external_clock_speed(is);---------外部时钟同步if (!display_disable && is->show_mode != SHOW_MODE_VIDEO && is->audio_st) {......}--------音频波形图显示if (is->video_st) {......}------播放视频画面is->force_refresh = 0;if (show_status) {......}--------日志
}

1,当主时钟是外部时钟的逻辑。(第一个绿色框)

2,当需要播放音频的波形图的时候。(第二个绿色框)

3,渲染视频流的数据到窗口上。注意变量 is->force_refresh ,这个变量是控制是否渲染SDL画面的,渲染完之后会恢复成 0 。(第三个红色框)

4,打印音视频的同步信息到控制台上。(第四个红色框)

绿色的是不重要的,默认情况是不会跑进去绿色的逻辑。最重要的是红色的圈圈。

video_refresh() 函数里面最重要的就是 if (is->video_st)){...} ,因为就是在这块代码里面控制视频流的播放。

下面来宏观看一下 if (is->video_st)){...} 这段代码里面的逻辑,如下:

    if (is->video_st) {
retry:if (frame_queue_nb_remaining(&is->pictq) == 0) {// nothing to do, no picture to display in the queue} else {......}---------设置force_refresh为1display:/* display picture */if (!display_disable && is->force_refresh && is->show_mode == SHOW_MODE_VIDEO && is->pictq.rindex_shown)video_display(is);}is->force_refresh = 0;

首先里面有两个 label,分别是 retry 跟 display。可以看到,如果 FrameQueue 队列没有数据,就会立即跑到 display 的位置调 video_display() 函数显示画面。

video_display() 这个函数主要是负责把 视频帧 AVFrame 的数据渲染到 SDL_Texture(纹理)上面。

非常值得注意的是 video_display() 取的是上一帧视频来播放的,里面调用的函数是 frame_queue_peek_last() 。

这里说的上一帧视频,我指的是当前窗口画面正在显示的视频帧,只要它显示在窗口上了,它就是上一帧了,而下一帧代表还没播放显示的帧。

这里读者可能会疑惑,如果 video_display() 取的是上一帧,那怎么行?画面就一直是上一帧,画面就不会动。

答:没错,所以我缩进起来的逻辑 else{…},会调 frame_queue_next() 来偏移读索引,这样就会导致下一帧变成了上一帧。

video_display() 里面也有显示音频波形图的逻辑,但不是本文重点。

所以从宏观上video_refresh() 函数有两个逻辑。

第一FrameQueue 队列无数据可读,取上一帧来渲染SDL窗口,通常是因为调整了窗口大小才会执行 video_display() 重新渲染。如下:

            switch (event.key.keysym.sym) {case SDLK_f:toggle_full_screen(cur_stream);cur_stream->force_refresh = 1;break;case SDLK_p:

注意 is->force_refresh 这个变量只有是 1 才会 执行 video_display() 。执行完 video_refresh() 之后 is->force_refresh 会重新变成 0。

第二,FrameQueue 队列有数据可读,就会跑进去 else{…} 的逻辑,peek 一个帧,看看是否可以播放,如果可以播放,设置 is->force_refresh 为 1,然后再 执行 video_display() 渲染画面。

下面再来具体分析一下缩进起来的 else{...} 的逻辑,如下:

    if (is->video_st) {
retry:if (frame_queue_nb_remaining(&is->pictq) == 0) {// nothing to do, no picture to display in the queue} else {double last_duration, duration, delay;Frame *vp, *lastvp;/* dequeue the picture */lastvp = frame_queue_peek_last(&is->pictq);vp = frame_queue_peek(&is->pictq);if (vp->serial != is->videoq.serial) {frame_queue_next(&is->pictq);goto retry;}

可以看到,上面一个循环,不断从 FrameQueue 队列读取数据,直至读到跟 is->videoq.serial 序列号一致的 Frame,这样做可以把失效的 Frame 通通丢弃。因为快进快退的时候,会导致队列里面缓存的 Frame 失效。

else{...} 里面接下来的重点是如下:

            if (lastvp->serial != vp->serial)is->frame_timer = av_gettime_relative() / 1000000.0;if (is->paused)goto display;------------暂停状态/* compute nominal last_duration */last_duration = vp_duration(is, lastvp, vp);delay = compute_target_delay(last_duration, is);--------delay代表当前画面需要播放多久time= av_gettime_relative()/1000000.0;if (time < is->frame_timer + delay) {*remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);goto display;}is->frame_timer += delay;if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)is->frame_timer = time;------------frame_timer初始化

is->frame_timer 这个变量出现的频率非常高,所以需要先讲解一下 frame_timer 这个变量的含义。

frame_timer 可以理解为 窗口正在显示的帧 的播放时刻,就是说这帧是何时开始播放的,从何时开始显示到窗口上的。

其实还有另一个变量也是用来记录 视频帧的播放时刻的,那就是视频时钟 clock。 is->frame_timer 跟 clock 是同时更新的,如下:

            is->frame_timer += delay;if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)is->frame_timer = time;---------------------SDL_LockMutex(is->pictq.mutex);if (!isnan(vp->pts))update_video_pts(is, vp->pts, vp->pos, vp->serial);-----------更新视频时钟SDL_UnlockMutex(is->pictq.mutex);

只不过 frame_timer 的时间单位是系统时间,而 clock 的时间单位是 pts。两者有不同的用途。

av_gettime_relative() 函数可以简单理解为获取系统时间,只不过它是从一个任意位置开始的系统时间。

现在已经了解了 is->frame_timer 变量的含义,再来分析一下 frame_timer 的第一次赋值,以及后续变化的过程逻辑。

1, is->frame_timer 变量第一次赋值的地方是在 下面的代码,如下:

time= av_gettime_relative()/1000000.0;
...
if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)is->frame_timer = time;

这几句代码的本意是,如果 当前系统时间 比 当前帧的开始播放时刻 大 0.1 (AV_SYNC_THRESHOLD_MAX),就会重置 frame_timer 为当前系统时间。

这种情况可能是因为某些原因,视频流播放线程卡顿了很久,导致 当前时间与 frame_timer 差距过大,也就是上一帧显示得太久了。

但是这几句代码,也是第一次赋值 frame_timer 的代码,frame_timer 一开始是 0 ,所以 time 减去 is->frame_timer 必然大于 AV_SYNC_THRESHOLD_MAX

。所以,frame_timer 就会在这里第一次被赋值为系统时间。

**2,**如果不进行快进快退,frame_timer 就会一直累加 delay,如下:

            /* compute nominal last_duration */last_duration = vp_duration(is, lastvp, vp);delay = compute_target_delay(last_duration, is);-------------time= av_gettime_relative()/1000000.0;if (time < is->frame_timer + delay) {*remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);goto display;}is->frame_timer += delay;-------------累加delayif (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)is->frame_timer = time;

vp_duration() 函数是用来获取 窗口正在显示的帧 需要显示多长时间的。

在 video_refresh() 里面,比较容易混淆上一帧,当前帧,下一帧的概念。我在本文说的上一帧就是当前帧的意思,lastvp 就是窗口正在显示的帧,但是 last 直译过来,就是上一个的意思。对于这些概念,我尽量讲得具体一些。

compute_target_delay() 函数里面会进行视频同步操作,会把 last_duration 减少或者增加,然后返回 delay。所以 delay 可能会比 last_duration 大一点或者小一点,也可能 delay 等于 last_duration 。

如果视频流比音频流播放慢了,那 delay 会比 last_duration 小一些,如果视频流比音频流播放快了,那 delay 会比 last_duration 大一些。

last_duration 代表当前帧本来,本来需要显示多长时间。当前帧是 指 窗口正在显示的帧。

delay 代表 当前帧实际,实际应该显示多长时间。

举个例子,1/24 帧的视频流,每帧固定显示 0.04s,当音频跟视频播放完全同步的时候,last_duration 跟 delay 都会是 0.04。

但是当视频比音频快了 0.05s 的时候,那 delay 就会从 0.04 变成 0.08,翻倍了,拉长当前视频帧的播放时间来等待音频流追上来。

这个翻倍是 compute_target_delay() 函数的算法规则,本文不打算讲解视频同步的更多算法细节,只是简单讲一下 last_duration 跟 delay 变量的关系。

3,如果进行了快进快退,is->frame_timer 就会重新赋值为系统时间。

if (lastvp->serial != vp->serial)is->frame_timer = av_gettime_relative() / 1000000.0;

通常情况下,event_loop 函数是每隔 0.01s 来检测是否可以播放下一帧视频,检测代码如下:

time= av_gettime_relative()/1000000.0;
if (time < is->frame_timer + delay) {*remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);goto display;
}

上面说过 frame_timer 为 当前帧 的播放时刻,delay 代表当前帧实际应该显示多长时间,如果当前帧还没显示完,就会直接 goto 到 display 的位置,这时候 force_refresh 是 0 ,所以不会调 video_display() 进行渲染,什么都不会做,直接退出 video_refresh() 了。

还有一个重点是暂停状态下的逻辑,如下:

            if (lastvp->serial != vp->serial)is->frame_timer = av_gettime_relative() / 1000000.0;if (is->paused)goto display;------------------/* compute nominal last_duration */last_duration = vp_duration(is, lastvp, vp);delay = compute_target_delay(last_duration, is);

为什么暂停状态下 要直接跳到 display ?暂停状态下直接 return,或者在 入口检查一下,直接退出 video_refresh() 就行了。暂停状态下,肯定不需要播放视频,渲染窗口的啊?

解答一下:暂时状态下,是不需要再播放视频流的下一帧的,但是有可能需要重新渲染窗口。因为,因为暂停状态下,你可以调整 ffplay 的窗口大小的,之前说过,当前窗口大小变了,就需要去上一帧来重新渲染SDL。

这就是 暂停状态下 跳到 display 的意义,调整了窗口大小,就会导致 force_refresh 置为 1,跳到 display 的时候,就会跑进去 if 条件,执行 video_display() 函数。

video_refresh() 里面有两段检查视频帧时间的逻辑。

1,检查当前帧是否已经显示完毕。(前面的代码逻辑)

2,检查要播放的下一帧是否已经过了播放时间。(如下)

            if (frame_queue_nb_remaining(&is->pictq) > 1) {Frame *nextvp = frame_queue_peek_next(&is->pictq);duration = vp_duration(is, vp, nextvp);if(!is->step && (framedrop>0 || (framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) && time > is->frame_timer + duration){--------------------------------要播放的帧是否已经过期is->frame_drops_late++;frame_queue_next(&is->pictq);goto retry;--------------------------}}if (is->subtitle_st) {......}frame_queue_next(&is->pictq);----------------可以显示要播放的帧了is->force_refresh = 1;

上面这种情况是这样的,假设视频流的每帧应该只显示 0.04s,但是由于系统卡顿,第三帧显示了 0.1s秒,这个值大于了 is->frame_timer + duration,所以就会导致丢帧,会把地第 4 帧丢弃,转而去播放第 5 帧。

is->frame_drops_late 是统计从 FrameQueue 读取数据的时候的丢帧数量。

当两段时间检测逻辑都通过了之后,就可以显示要播放的帧的,如下:

frame_queue_next(&is->pictq);
is->force_refresh = 1;

这两句代码非常精妙,frame_queue_next() 会偏移读索引,所以导致了下一帧变成了当前帧,或者说导致下一帧变成了上一帧。本文我说的当前帧就是上一帧。而 force_refresh 置为 1 后,就可以调 video_diaplay()了。

下面再分析最后一个重点,就是 video_diaplay() 的判断逻辑,如下:

display:/* display picture */if (!display_disable && is->force_refresh && is->show_mode == SHOW_MODE_VIDEO && is->pictq.rindex_shown)video_display(is);

display_disable 默认是 0,可以通过命令行参数改变,主要就是控制窗口不要显示画面,无论是视频帧,还是音频波形都不显示。

is->force_refresh 变量有两种情况会置为 1,一是当下一帧可以播放的时候,二是当窗口大小产生变化的时候。

is->show_mode 默认就是 SHOW_MODE_VIDEO 。

最后一个条件 is->pictq.rindex_shown 是重点,有点不太容易看出来他为什么在 if 加上这个判断。

之前在《FrameQueue队列分析》讲过,rindex_shown 的初始值是 0。只有在插入第一帧到 FrameQueue 的时候, rindex_shown 变量才会变成 1。

所以,加上 is->pictq.rindex_shown 条件,就是为了防止 FrameQueue 一帧数据都没有,就调了 video_display()。

从代码逻辑上看,当 FrameQueue 队列为空的时候,是会直接跳到 diaplay 的,所以如果不在 if 加上 is->pictq.rindex_shown,会有问题。

    if (is->video_st) {
retry:if (frame_queue_nb_remaining(&is->pictq) == 0) {// nothing to do, no picture to display in the queue} else {......}
display:---------------------framequeue队列无数据,会直接跑到display/* display picture */if (!display_disable && is->force_refresh && is->show_mode == SHOW_MODE_VIDEO && is->pictq.rindex_shown)-----rindex_shownvideo_display(is);}

总结:if 条件里面的is->pictq.rindex_shown ,是用来防止播放线程启动运行得太快,FrameQueue 什么都没有的时候,就调 video_display();


注意,最后渲染完画面之后,is->force_refresh 会重新赋值 为 0。

display:/* display picture */if (!display_disable && is->force_refresh && is->show_mode == SHOW_MODE_VIDEO && is->pictq.rindex_shown)video_display(is);}is->force_refresh = 0;--------------------force_refresh = 0if (show_status) {

后面的 if (show_status){...} 是输出控制台的日志,显示音频时钟跟视频时钟的时间差。

video_refresh() 视频播放线程目前就讲解完毕了,里面有一个变量忽略了,就是 is->step ,这个变量是 FFplay 的逐帧播放功能,默认是 0 。

FFplay视频同步分析

以音频时钟为主时钟,是最常用的同步方式,也是FFplay里面默认的同步方式。当以音频时钟为主时钟,视频 就会向音频同步。

视频播放线程,会缩短或者拉长当前视频帧的播放时长,或者丢弃视频帧来向音频同步。

FFplay 是用 struct Clock 数据结构记录音频流,视频流当前播放到哪里的。Clock 结构体的定义如下:

typedef struct Clock {double pts;           /* clock base */ 单位是妙。double pts_drift;     /* clock base minus time at which we updated the clock */double last_updated;double speed;int serial;           /* clock is based on a packet with this serial */int paused;int *queue_serial;    /* pointer to the current packet queue serial, used for obsolete clock detection */
} Clock;

每次取下一帧视频来播放的时候,就会更新视频时钟,记录那时候视频流播放到哪里了,如下:

            /* compute nominal last_duration */last_duration = vp_duration(is, lastvp, vp);delay = compute_target_delay(last_duration, is);time= av_gettime_relative()/1000000.0;if (time < is->frame_timer + delay) {*remaining_time = FFMIN(is->frame_timer + delay - time, *remaining_time);goto display;}is->frame_timer += delay;if (delay > 0 && time - is->frame_timer > AV_SYNC_THRESHOLD_MAX)is->frame_timer = time;SDL_LockMutex(is->pictq.mutex);if (!isnan(vp->pts))update_video_pts(is, vp->pts, vp->pos, vp->serial);---------------更新视频时钟SDL_UnlockMutex(is->pictq.mutex);

每次回调取音频数据来播放的时候,就会更新音频时钟,记录那时候音频流播放到哪里了,如下:

/* prepare a new audio buffer */
static void sdl_audio_callback(void *opaque, Uint8 *stream, int len)
{VideoState *is = opaque;int audio_size, len1;audio_callback_time = av_gettime_relative();while (len > 0) {。。。。。。}is->audio_write_buf_size = is->audio_buf_size - is->audio_buf_index;/* Let's assume the audio driver that is used by SDL has two periods. */if (!isnan(is->audio_clock)) {set_clock_at(&is->audclk, is->audio_clock - (double)(2 * is->audio_hw_buf_size + is->audio_write_buf_size) / is->audio_tgt.bytes_per_sec, is->audio_clock_serial, audio_callback_time / 1000000.0);------------更新音频时钟sync_clock_to_slave(&is->extclk, &is->audclk);}
}

提醒:Clock::pts 的时间单位是秒。

struct Clock 里面最难懂的字段就是 pts_driftpts_drift 字段的值是通过 pts 字段减去系统时间得到的,所以实际运行的时候 pts_drift 是一个很大的负数。如下:

static void set_clock_at(Clock *c, double pts, int serial, double time)
{c->pts = pts;c->last_updated = time;c->pts_drift = c->pts - time;c->serial = serial;
}

pts_drift 是一个很大的负数,会让你摸不着头脑,不知道是干什么的?我们来看一下有什么地方会用到 pts_drift 这个变量,如下:

static double get_clock(Clock *c)
{if (*c->queue_serial != c->serial)return NAN;if (c->paused) {return c->pts;} else {double time = av_gettime_relative() / 1000000.0;return c->pts_drift + time - (time - c->last_updated) * (1.0 - c->speed);-----------c->speed等于1}
}static void set_clock_at(Clock *c, double pts, int serial, double time)
{c->pts = pts;c->last_updated = time;c->pts_drift = c->pts - time;c->serial = serial;
}

上面代码中的 get_clock() 函数是用来获取 当前视频流或者音频流播放到哪里的。后面的 c->speed 默认是 1 ,所以不用管。

get_clock() 里面会用 av_gettime_relative() 获取当前的系统时间,所以 get_clock() 的计算公式如下:

视频流当前的播放时刻 = 当前帧的 pts - 之前记录的系统时间 + 当前的系统时间
视频流当前的播放时刻 = 当前帧的 pts + 当前的系统时间 - 之前记录的系统时间
视频流当前的播放时刻 = 当前帧的 pts + 消逝的时间

pts_drift 字段的真正作用这样是为了能计算出消逝的时间,这样才能在每时每刻都能准确知道当前视频流或者音频的播放到哪里了。如果你想知道视频流播放到哪里了,调一下 get_clock() 函数即可。

所以 pts_drift 字段实际上是由两个字段组成,当前帧的 pts 跟 之前记录的系统时间,这两个字段的信息量合在一起存储就会看起来有点奇怪,是一个很大的负数。

我们所处的世界中的时间是不断消逝的,所以 set_clock_at() 需要同时记录上当时的系统时间,方便后面的 get_clock() 能计算出消逝了的时间。

现在已经明白了 Clock 时钟的概念,也知道 用 get_clock() 函数能获取到 视频流,音频流当前的播放时刻,下面就来正式进入本文的主题,视频同步的逻辑。

视频向音频同步的逻辑有两处,其中一处在 compute_target_delay() 函数里面,如下:

/* compute nominal last_duration */
last_duration = vp_duration(is, lastvp, vp);
delay = compute_target_delay(last_duration, is);

last_duration 代表当前帧本来,本来需要显示多长时间。当前帧是 指 窗口正在显示的帧。

delay 代表 当前帧实际,实际应该显示多长时间。

举个例子,1/24 帧的视频流,每帧固定显示 0.04s,当音频跟视频播放不同步的差异不超过阈值的时候,last_duration 跟 delay 都会是 0.04。

但是当视频比音频快了 0.05s 的时候,那 delay 就会从 0.04 变成 0.08,翻倍了,拉长当前视频帧的播放时间来等待音频流追上来。

当视频比音频慢了 0.05s 的时候,那 delay 就会从 0.04 变成 0,这样当前视频帧就会立即结束播放,让下一帧显示出来。

下面来分析一下 compute_target_delay() 函数的实现,如下:

static double compute_target_delay(double delay, VideoState *is)
{double sync_threshold, diff = 0;/* update delay to follow master synchronisation source */if (get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER) {---------------------/* if video is slave, we try to correct big delays byduplicating or deleting a frame */diff = get_clock(&is->vidclk) - get_master_clock(is);-------------------/* skip or repeat frame. We take into account thedelay to compute the threshold. I still don't knowif it is the best guess */sync_threshold = FFMAX(AV_SYNC_THRESHOLD_MIN, FFMIN(AV_SYNC_THRESHOLD_MAX, delay));---------------if (!isnan(diff) && fabs(diff) < is->max_frame_duration) {if (diff <= -sync_threshold)delay = FFMAX(0, delay + diff);else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD)delay = delay + diff;else if (diff >= sync_threshold)delay = 2 * delay;}}av_log(NULL, AV_LOG_TRACE, "video: delay=%0.3f A-V=%f\n",delay, -diff);return delay;
}

可以看到,如果是音频时钟为主时钟,就会跑进去 if 里面的逻辑。

变量 diff 代表视频时钟与主时钟的时间差,主时钟默认是音频时钟。当 diff 大于 0 的时候,代表 视频时钟 比 音频时钟 快。当 diff 小于 0 的时候,代表 视频时钟 比 音频时钟 慢。diff 的单位是秒。

之前讲过,音视频不同步是常态,不需要做到完全同步,只要把不同步的程度控制在阈值范围内,人就感受不到不同步了。

FFplay 里面计算同步阈值(sync_threshold)的方式有点复杂,如下:

sync_threshold = FFMAX(AV_SYNC_THRESHOLD_MIN, FFMIN(AV_SYNC_THRESHOLD_MAX, delay));

先介绍一下两个宏:

1,AV_SYNC_THRESHOLD_MIN,最小的同步阈值,值为 0.04,单位是 秒

2,AV_SYNC_THRESHOLD_MAX,最大的同步阈值,值为 0.1,单位是 秒

上面的代码,就是从 0.04 ~ 0.1 之间选出一个值作为 同步阈值。

对于 1/12帧的视频,delay 是 0.082,所以 sync_threshold 等于 0.082,等于一帧的播放时长。

对于 1/24 帧的视频,delay 是 0.041,所以 sync_threshold 等于 0.041,等于一帧的播放时长。

对于 1/48 帧的视频,delay 是 0.0205,所以 sync_threshold 等于 0.04,约等于两帧的播放时长。

这就是 FFplay 计算同步阈值(sync_threshold) 的算法。

计算出同步阈值之后,就需要判断 音视频的时间差 diff 是否超过阈值,所以就有了下面的判断:

if (!isnan(diff) && fabs(diff) < is->max_frame_duration) {if (diff <= -sync_threshold)delay = FFMAX(0, delay + diff);else if (diff >= sync_threshold && delay > AV_SYNC_FRAMEDUP_THRESHOLD)delay = delay + diff;else if (diff >= sync_threshold)delay = 2 * delay;
}

is->max_frame_duration 通常是 10s,这个判断是当音视频不同步的差异超过 10s,就不再进行同步操作,不管摆烂。

diff 是负数的时候,代表视频比音频慢了,通常会将 delay 置为 0,说实话,我想不到 FFMAX(0, delay + diff) 能返回大于 0 的场景。

diff 是正数的时候,代表视频比音频快了,当超过阈值的时候,就会把 delay * 2。

这就是 FFplay 里面视频向音频同步的算法逻辑,不过写这段代码的作者也留了一句注释,如下:

We take into account the delay to compute the threshold. I still don't know if it is the best guess

他也不太清楚,这样计算出来的同步阈值(sync_threshold)是否是一个最好的实现。

不过 ffplay 通常播放视频没有问题,证明这个算法还是可以的,感兴趣的读者可以看一些 VLC 播放器的同步实现。

上面讲的是视频播放的时候的同步逻辑,而在视频帧刚解码出来的时候,FFplay 也需要会进行音视频同步,不过这里不会用到同步阈值,只要视频已经比音频慢了,无论慢多少,都会立即丢帧。如下:

static int get_video_frame(VideoState *is, AVFrame *frame)
{int got_picture;if ((got_picture = decoder_decode_frame(&is->viddec, frame, NULL)) < 0)------------解码return -1;if (got_picture) {double dpts = NAN;if (frame->pts != AV_NOPTS_VALUE)dpts = av_q2d(is->video_st->time_base) * frame->pts;frame->sample_aspect_ratio = av_guess_sample_aspect_ratio(is->ic, is->video_st, frame);if (framedrop>0 || (framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) {----------------if (frame->pts != AV_NOPTS_VALUE) {double diff = dpts - get_master_clock(is);-------------------if (!isnan(diff) && fabs(diff) < AV_NOSYNC_THRESHOLD &&diff - is->frame_last_filter_delay < 0 &&is->viddec.pkt_serial == is->vidclk.serial &&is->videoq.nb_packets) {is->frame_drops_early++;av_frame_unref(frame);got_picture = 0;}}}---------------}return got_picture;
}

framedrop 默认值是 -1。 注意 C99 标准是没有布尔值的,只要是非 0 的值,都是真,所以默认情况下,下面的条件就为真。

if (framedrop>0 || (framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) 默认等于 true

从上图可以看到,还是计算出 视频与音频的时间差 diff 。

下面这个判断比较复杂,有好几个条件符合才会进行丢帧。

if (frame->pts != AV_NOPTS_VALUE) {double diff = dpts - get_master_clock(is);if (!isnan(diff) && fabs(diff) < AV_NOSYNC_THRESHOLD &&diff - is->frame_last_filter_delay < 0 &&is->viddec.pkt_serial == is->vidclk.serial &&is->videoq.nb_packets) {is->frame_drops_early++;av_frame_unref(frame);got_picture = 0;}
}

1,音视频时间差 diff 不超过10s(AV_NOSYNC_THRESHOLD )

2,序列号一致,就是为了快进快退功能服务的。

3,PacketQueue 队列里面还有数据可以解码。

4,视频比音频播放慢了,也就是 diff - is->frame_last_filter_delay < 0 条件。

第四个条件是最难懂,为什么要减去 frame_last_filter_delay 呢?
diff 只要小于 0 了,就代表视频比音频播放慢了,这个无可厚非,diff 小于 0 ,它减去 frame_last_filter_delay ,必然还是小于 0 。

所以减去 frame_last_filter_delay是为了服务一些 diff 大于 0 的情况。

首先 frame_last_filter_delay 变量存储的是滤镜容器处理上一帧所花的时间,这是一个预估值,假设滤镜容器处理上一帧花了 0.01s,那处理现在这一帧估计也需要0.01s,所以解码出来的 AVFrame,并不是立即就能丢进去 FrameQueue 给播放线程用。而是需要经过滤镜处理的,滤镜处理也需要时间。

所以如果 diff 等于 0.008 ,视频比音频快了 0.008s,但是因为视频要经过滤镜处理,所以需要减去 0.01 ,实际上是 视频比音频播放慢了 0.002s。

0.008 -0.01 = -0.02

最后会用 is->frame_drops_early 来记录一下这种情况的丢帧数量。

最后还有一个视频同步的地方,就是 is->frame_drops_late,但是这里没有用到音频时钟。

            if (frame_queue_nb_remaining(&is->pictq) > 1) {Frame *nextvp = frame_queue_peek_next(&is->pictq);duration = vp_duration(is, vp, nextvp);if(!is->step && (framedrop>0 || (framedrop && get_master_sync_type(is) != AV_SYNC_VIDEO_MASTER)) && time > is->frame_timer + duration){is->frame_drops_late++;frame_queue_next(&is->pictq);goto retry;}}

这段代码的逻辑是在 video_refresh() 视频播放线程里面的,当从 FrameQueue 队列拿到一个帧的时候,会判断这个帧是否已经过了它的播放时间,如果过了就丢帧。

最后会用 is->frame_drops_late 来记录一下这种情况的丢帧数量。

总结,视频同步一共有三处地方。

1,解码出来之后。

2,播放的时候,检测视频帧是否已经过了它的播放时间。

3,播放的时候,compute_target_delay() 让当前帧立即结束播放,或者延迟播放时间。


http://www.ppmy.cn/ops/109945.html

相关文章

Debian 12 中为 root 用户修改最大打开文件数进程数的限制

在 Debian 12 中&#xff0c;管理和配置打开文件的限制涉及到系统级别和用户级别的设置。以下是详细的步骤来修改和管理“打开文件”限制&#xff1a; 1. 查看当前的限制 首先&#xff0c;了解当前的限制配置&#xff1a; 系统级别&#xff1a; cat /proc/sys/fs/file-max这…

可测试,可维护,可移植:上位机软件分层设计的重要性

互联网中&#xff0c;软件工程师岗位会分前端工程师&#xff0c;后端工程师。这是由于互联网软件规模庞大&#xff0c;从业人员众多。前后端分别根据各自需求发展不一样的技术栈。那么上位机软件呢&#xff1f;它规模小&#xff0c;通常一个人就能开发一个项目。它还有必要分前…

移动订货小程序哪个好 批发订货系统源码哪个好

订货小程序就是依托微信小程序的订货系统&#xff0c;微信小程序订货系统相较于其他终端的订货方式&#xff0c;能够更快进入商城&#xff0c;对经销商而言更为方便。今天&#xff0c;我们一起盘点三个主流的移动订货小程序&#xff0c;看看哪个移动订货小程序好。 第一、核货宝…

unocss 一直热更新打印[vite] hot updated: /__uno.css

报错信息 "unocss 一直热更新打印 [vite] hot updated: /__uno.css" 表示你的项目正在使用 unocss 这个库&#xff0c;并且它正在不断地进行热更新。vite 是一个现代化的前端构建工具&#xff0c;这条信息实际上是 vite 在通知你有关于 __uno.css 文件的热更新发生了…

【2025】基于python的网上商城比价系统、智能商城比价系统、电商比价系统、智能商城比价系统(源码+文档+解答)

博主介绍&#xff1a; ✌我是阿龙&#xff0c;一名专注于Java技术领域的程序员&#xff0c;全网拥有10W粉丝。作为CSDN特邀作者、博客专家、新星计划导师&#xff0c;我在计算机毕业设计开发方面积累了丰富的经验。同时&#xff0c;我也是掘金、华为云、阿里云、InfoQ等平台…

discuz论坛3.4 截图粘贴图片发帖后显示不正常问题

处理方法 source\function 路径下修改function_discuzcode.php function bbcodeurl($url, $tags) 函数 if(!in_array(strtolower(substr($url, 0, 6)), array(http:/, https:, ftp://, rtsp:/, mms://,data:i) 这一句里增加 data:i 即可 function bbcodeurl($url,…

vue2 组件通信

props emits props:用于接收父组件传递给子组件的数据。可以定义期望从父组件接收的数据结构和类型。‘子组件不可更改该数据’emits:用于定义组件可以向父组件发出的事件。这允许父组件监听子组件的事件并作出响应。(比如数据更新) props检查属性 属性名类型描述默认值typ…

9.12日常记录

1.extern关键字 1&#xff09;诞生动机:在一个C语言项目中&#xff0c;需要再多个文件中使用同一全局变量或是函数&#xff0c;那么就需要在这些文件中再声明一遍 2&#xff09;用于声明在其他地方定义的一个变量或是函数&#xff0c;在当前位置只是声明&#xff0c;告诉编译器…