基于ffmpeg和sdl2的简单视频播放器制作

server/2025/1/8 14:49:11/

基于ffmpeg和sdl2的简单视频播放器制作

    • 前言
    • 一、视频播放器开发的基础
      • 1.1 视频播放原理
      • 1.2 开发所需的库
    • 二、FFmpeg库详解
      • 2.1 FFmpeg库的组成
      • 2.2 关键数据结构
      • 2.3 打开视频文件并获取流信息
      • 2.4 查找视频流和解码器
      • 2.5 初始化解码器
    • 三、SDL库详解
      • 3.1 SDL库的功能
      • 3.2 初始化SDL
      • 3.3 创建窗口、渲染器和纹理
      • 3.4 事件处理
    • 四、视频播放流程
      • 4.1 读取和解码视频帧
      • 4.2 时间同步
      • 4.3 图像格式转换与渲染
    • 五、资源清理
    • 六、完整代码示例
    • 七、总结与展望


前言

本文将简单探讨视频播放器的开发过程,通过一个完整的代码示例,带你领略从打开视频文件到播放视频画面的每一个关键步骤。


一、视频播放器开发的基础

1.1 视频播放原理

视频,本质上是一系列连续的图像帧快速播放所形成的视觉效果。在数字视频中,这些图像帧被编码压缩以减小文件大小,便于存储和传输。常见的视频编码格式有H.264、H.265等。同时,视频文件通常还包含音频数据,音频也经过特定的编码方式,如AAC、MP3等。

当我们播放视频时,播放器需要执行以下主要步骤:

  1. 解封装:从视频文件中分离出视频流和音频流。视频文件通常采用某种封装格式,如MP4、AVI等,这些格式将视频和音频数据按照一定的结构组织在一起。
  2. 解码:对分离出的视频流和音频流进行解码,将压缩的数据还原为原始的图像帧和音频样本。这需要使用相应的解码器,不同的编码格式需要不同的解码器。
  3. 渲染:将解码后的图像帧显示在屏幕上,同时将音频样本通过音频设备播放出来。这涉及到图形渲染和音频输出的相关技术。

1.2 开发所需的库

在开发视频播放器时,我们需要借助一些强大的开源库来简化开发过程。在本文的示例中,我们主要使用了以下两个库:

  1. FFmpeg:这是一个功能强大的开源多媒体框架,提供了丰富的工具和函数,用于处理多媒体文件的解封装、解码、编码等操作。它支持几乎所有常见的多媒体格式,并且具有高效的性能。
  2. SDL (Simple DirectMedia Layer):这是一个跨平台的多媒体开发库,专注于提供硬件抽象层,用于创建窗口、渲染图形、播放音频以及处理输入事件等。它使得我们能够在不同的操作系统上轻松实现一致的多媒体交互功能。

二、FFmpeg库详解

2.1 FFmpeg库的组成

FFmpeg库由多个模块组成,每个模块都有其特定的功能:

  1. libavformat:负责处理多媒体文件的格式,包括解封装和封装操作。它能够识别各种常见的视频和音频封装格式,如MP4、AVI、FLV等,并从中提取出视频流和音频流。
  2. libavcodec:这是FFmpeg的核心编解码模块,支持众多的音频和视频编码格式。它包含了各种解码器和编码器,能够将压缩的多媒体数据进行解码或编码操作。
  3. libavutil:提供了一系列通用的工具函数和数据结构,如内存管理、错误处理、数学运算等。这些工具函数在整个FFmpeg库的其他模块中被广泛使用。
  4. libswscale:用于图像的缩放和格式转换。在视频播放过程中,由于解码后的图像格式可能与显示设备所需的格式不一致,需要使用该模块进行转换。
  5. libswresample:主要用于音频的重采样和格式转换。它可以将音频数据从一种采样率、声道数或样本格式转换为另一种,以适应不同的音频输出设备。

2.2 关键数据结构

  1. AVFormatContext:这个结构体是FFmpeg中用于管理多媒体文件格式的上下文。它包含了文件的各种信息,如流的数量、每个流的参数等。在打开视频文件时,我们会创建一个AVFormatContext对象,并使用它来读取文件的信息和解封装数据。
  2. AVCodecContext:代表编解码器的上下文,包含了编解码所需的各种参数,如编码格式、分辨率、帧率等。在找到合适的解码器后,我们需要创建一个AVCodecContext对象,并将其与解码器进行关联。
  3. AVFrame:用于存储解码后的音频或视频数据。对于视频来说,它包含了一帧图像的像素数据;对于音频来说,它包含了音频样本数据。
  4. AVPacket:用于存储从文件中读取的压缩数据,这些数据在经过解封装后以AVPacket的形式存在,然后被传递给解码器进行解码。

2.3 打开视频文件并获取流信息

在我们的代码示例中,首先需要打开视频文件并获取其流信息:

AVFormatContext* fmt_ctx = NULL;
std::string file_path = "F:/QT/mp4_flv/x.mp4";// 打开视频文件
int ret = avformat_open_input(&fmt_ctx, file_path.c_str(), NULL, NULL);
if (ret < 0) {handle_ffmpeg_error(ret, "Failed to open video file.");return -1;
}// 读取视频流信息
ret = avformat_find_stream_info(fmt_ctx, NULL);
if (ret < 0) {handle_ffmpeg_error(ret, "Error in obtaining video stream information.");avformat_close_input(&fmt_ctx);return -1;
}

这里,avformat_open_input函数用于打开指定路径的视频文件,并将文件信息存储在fmt_ctx中。如果打开失败,会调用handle_ffmpeg_error函数进行错误处理。接着,avformat_find_stream_info函数用于读取视频文件中的流信息,包括视频流和音频流的参数等。同样,如果读取失败,也会进行相应的错误处理。

2.4 查找视频流和解码器

const AVCodec* codec = NULL;
int video_stream_idx = -1;
for (unsigned int i = 0; i < fmt_ctx->nb_streams; i++) {if (fmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {video_stream_idx = i;codec = avcodec_find_decoder(fmt_ctx->streams[i]->codecpar->codec_id);if (!codec) {fprintf(stderr, "Video decoder not found\n");avformat_close_input(&fmt_ctx);return -1;}break;}
}

这段代码通过遍历fmt_ctx中的所有流,找到类型为视频流(AVMEDIA_TYPE_VIDEO)的流,并获取其索引video_stream_idx。然后,根据流的编码ID,使用avcodec_find_decoder函数查找对应的解码器。如果找不到解码器,会输出错误信息并关闭文件。

2.5 初始化解码器

找到解码器后,需要对其进行初始化:

AVCodecContext* codec_ctx = avcodec_alloc_context3(codec);
if (!codec_ctx) {fprintf(stderr, "Decoder context allocation failed\n");avformat_close_input(&fmt_ctx);return -1;
}ret = avcodec_parameters_to_context(codec_ctx, fmt_ctx->streams[video_stream_idx]->codecpar);
if (ret < 0) {handle_ffmpeg_error(ret, "Copying codec parameters failed!");avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return -1;
}ret = avcodec_open2(codec_ctx, codec, NULL);
if (ret < 0) {handle_ffmpeg_error(ret, "Decoder open failed!");avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return -1;
}

首先,使用avcodec_alloc_context3函数分配一个AVCodecContext对象。然后,将流的编码参数复制到codec_ctx中,最后使用avcodec_open2函数打开解码器。每一步操作都进行了错误处理,确保解码器能够正确初始化。

三、SDL库详解

3.1 SDL库的功能

SDL库主要用于创建图形窗口、渲染图像以及处理用户输入事件。它提供了一系列简单易用的函数,使得我们能够在不同的操作系统上实现一致的图形界面和交互功能。

  1. 窗口管理:SDL可以创建和管理窗口,设置窗口的大小、位置、标题等属性。它还支持窗口的最小化、最大化、关闭等操作。
  2. 图形渲染:提供了多种渲染方式,包括软件渲染和硬件加速渲染。可以将图像数据渲染到窗口上,实现视频画面的显示。
  3. 事件处理:能够捕获和处理各种用户输入事件,如鼠标点击、键盘按键、窗口关闭等事件,使得我们的程序能够响应用户的操作。

3.2 初始化SDL

在使用SDL之前,需要先对其进行初始化:

if (SDL_Init(SDL_INIT_VIDEO) < 0) {fprintf(stderr, "SDL could not initialize! SDL_Error: %s\n", SDL_GetError());av_frame_free(&frame);avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return -1;
}

这里使用SDL_Init函数初始化SDL库的视频子系统。如果初始化失败,会输出错误信息并释放之前分配的资源。

3.3 创建窗口、渲染器和纹理

SDL_Window* window = SDL_CreateWindow("Video Player", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,codec_ctx->width, codec_ctx->height, SDL_WINDOW_SHOWN);
if (!window) {fprintf(stderr, "Window could not be created! SDL_Error: %s\n", SDL_GetError());SDL_Quit();av_frame_free(&frame);avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return -1;
}SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);
if (!renderer) {fprintf(stderr, "Renderer could not be created! SDL_Error: %s\n", SDL_GetError());SDL_DestroyWindow(window);SDL_Quit();av_frame_free(&frame);avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return -1;
}SDL_Texture* texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_YV12, SDL_TEXTUREACCESS_STREAMING,codec_ctx->width, codec_ctx->height);
if (!texture) {fprintf(stderr, "Texture could not be created! SDL_Error: %s\n", SDL_GetError());SDL_DestroyRenderer(renderer);SDL_DestroyWindow(window);SDL_Quit();av_frame_free(&frame);avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return -1;
}

这段代码依次创建了窗口、渲染器和纹理。SDL_CreateWindow函数用于创建一个指定大小和标题的窗口。SDL_CreateRenderer函数创建一个渲染器,用于将图像渲染到窗口上,这里使用了硬件加速渲染(SDL_RENDERER_ACCELERATED)。最后,SDL_CreateTexture函数创建一个纹理,用于存储视频图像数据,以便后续渲染。同样,每一步创建操作都进行了错误处理,如果创建失败,会释放之前创建的资源。

3.4 事件处理

在视频播放过程中,需要处理用户的输入事件,如关闭窗口事件:

SDL_Event event;
while (SDL_PollEvent(&event)) {if (event.type == SDL_QUIT) {goto cleanup;}
}

这里使用SDL_PollEvent函数不断检查是否有事件发生。如果检测到SDL_QUIT事件(即用户点击了窗口的关闭按钮),则跳转到cleanup标签处,进行资源清理操作。

四、视频播放流程

4.1 读取和解码视频帧

在初始化完成后,进入视频播放的主循环,不断从视频文件中读取数据包并进行解码:

AVPacket pkt;
while (av_read_frame(fmt_ctx, &pkt) >= 0) {if (pkt.stream_index == video_stream_idx) {ret = avcodec_send_packet(codec_ctx, &pkt);if (ret < 0) {handle_ffmpeg_error(ret, "send data decoder error.");av_packet_unref(&pkt);continue;}while (ret >= 0) {ret = avcodec_receive_frame(codec_ctx, frame);if (ret == 0) {// 处理解码后的帧}else if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {break;}}}av_packet_unref(&pkt);
}

在循环中,使用av_read_frame函数从视频文件中读取一个数据包pkt。如果数据包属于视频流(通过pkt.stream_index判断),则将其发送给解码器进行解码。avcodec_send_packet函数将数据包发送给解码器,avcodec_receive_frame函数从解码器中接收解码后的帧。如果解码成功(ret == 0),则可以对解码后的帧进行进一步处理;如果解码器需要更多数据(ret == AVERROR(EAGAIN))或已经到达文件末尾(ret == AVERROR_EOF),则退出内层循环。每次处理完数据包后,使用av_packet_unref函数释放数据包的引用。

4.2 时间同步

为了保证视频播放的流畅性和音频视频的同步,需要进行时间同步:

int64_t start_time = av_gettime();// 在解码帧的处理部分
int64_t pts = frame->pts;
if (pts == AV_NOPTS_VALUE) {pts = av_rescale_q(pkt.dts, fmt_ctx->streams[video_stream_idx]->time_base, { 1, 1000000 });
}
else {pts = av_rescale_q(frame->pts, fmt_ctx->streams[video_stream_idx]->time_base, { 1, 1000000 });
}int64_t now = av_gettime();
if (pts > now - start_time) {SDL_Delay((pts - (now - start_time)) / 1000);
}

这里使用av_gettime函数获取当前时间。通过帧的显示时间戳(pts)和当前时间的比较,计算出需要延迟的时间,使用SDL_Delay函数进行延迟,以确保视频帧按照正确的时间顺序显示。

4.3 图像格式转换与渲染

解码后的帧需要进行格式转换并渲染到窗口上:

SDL_Rect rect = { 0, 0, codec_ctx->width, codec_ctx->height };
sws_ctx = sws_getContext(codec_ctx->width, codec_ctx->height, codec_ctx->pix_fmt,codec_ctx->width, codec_ctx->height, AV_PIX_FMT_YUV420P, SWS_BICUBIC, NULL, NULL, NULL);// 在解码帧的处理部分
SDL_UpdateYUVTexture(texture, &rect,frame->data[0], frame->linesize[0],frame->data[1], frame->linesize[1],frame->data[2], frame->linesize[2]);SDL_RenderClear(renderer);
SDL_RenderCopy(renderer, texture, NULL, NULL);
SDL_RenderPresent(renderer);

首先,使用sws_getContext函数创建一个图像格式转换上下文sws_ctx,将解码后的帧格式转换为适合SDL渲染的格式(这里是AV_PIX_FMT_YUV420P)。然后,使用SDL_UpdateYUVTexture函数将转换后的帧数据更新到纹理中。接着,使用SDL_RenderClear函数清空渲染器,SDL_RenderCopy函数将纹理数据复制到渲染器上,最后使用SDL_RenderPresent函数将渲染器的内容显示在窗口上。

五、资源清理

在视频播放结束后,需要释放所有分配的资源,以避免内存泄漏:

cleanup:sws_freeContext(sws_ctx);SDL_DestroyTexture(texture);SDL_DestroyRenderer(renderer);SDL_DestroyWindow(window);SDL_Quit();av_frame_free(&frame);avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);

这里依次释放了图像格式转换上下文、纹理、渲染器、窗口、SDL库资源、视频帧、解码器上下文以及视频文件格式上下文。

六、完整代码示例


#include <iostream>
#include <string>
#include <SDL2\SDL.h>
extern "C" {
#include <libavcodec\avcodec.h>
#include <libavformat\avformat.h>
#include <libavutil\avutil.h>
#include <libswscale\swscale.h>
#include <libswresample/swresample.h>
#include <libavutil/channel_layout.h>
#include <libavutil/opt.h>
#include <libavutil\pixfmt.h>
#include <libavutil/imgutils.h>
#include <libavutil/time.h>
}
#include <chrono> // 用于时间同步
#include <thread>void handle_ffmpeg_error(int ret, const char* msg) {char errbuf[AV_ERROR_MAX_STRING_SIZE];av_strerror(ret, errbuf, AV_ERROR_MAX_STRING_SIZE);fprintf(stderr, "%s: %s\n", msg, errbuf);
}
#undef main
int main() {AVFormatContext* fmt_ctx = NULL;std::string file_path = "F:/QT/mp4_flv/x.mp4";// 打开视频文件int ret = avformat_open_input(&fmt_ctx, file_path.c_str(), NULL, NULL);if (ret < 0) {handle_ffmpeg_error(ret, "Failed to open video file.");return -1;}// 读取视频流信息ret = avformat_find_stream_info(fmt_ctx, NULL);if (ret < 0) {handle_ffmpeg_error(ret, "Error in obtaining video stream information.");avformat_close_input(&fmt_ctx);return -1;}// 查找视频流和解码器const AVCodec* codec = NULL;int video_stream_idx = -1;for (unsigned int i = 0; i < fmt_ctx->nb_streams; i++) {if (fmt_ctx->streams[i]->codecpar->codec_type == AVMEDIA_TYPE_VIDEO) {video_stream_idx = i;codec = avcodec_find_decoder(fmt_ctx->streams[i]->codecpar->codec_id);if (!codec) {fprintf(stderr, "Video decoder not found\n");avformat_close_input(&fmt_ctx);return -1;}break;}}AVCodecContext* codec_ctx = avcodec_alloc_context3(codec);if (!codec_ctx) {fprintf(stderr, "Decoder context allocation failed\n");avformat_close_input(&fmt_ctx);return -1;}ret = avcodec_parameters_to_context(codec_ctx, fmt_ctx->streams[video_stream_idx]->codecpar);if (ret < 0) {handle_ffmpeg_error(ret, "Copying codec parameters failed!");avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return -1;}ret = avcodec_open2(codec_ctx, codec, NULL);if (ret < 0) {handle_ffmpeg_error(ret, "Decoder open failed!");avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return -1;}AVFrame* frame = av_frame_alloc();SwsContext* sws_ctx = NULL;if (SDL_Init(SDL_INIT_VIDEO) < 0) {fprintf(stderr, "SDL could not initialize! SDL_Error: %s\n", SDL_GetError());av_frame_free(&frame);avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return -1;}SDL_Window* window = SDL_CreateWindow("Video Player", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,codec_ctx->width, codec_ctx->height, SDL_WINDOW_SHOWN);if (!window) {fprintf(stderr, "Window could not be created! SDL_Error: %s\n", SDL_GetError());SDL_Quit();av_frame_free(&frame);avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return -1;}SDL_Renderer* renderer = SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED);if (!renderer) {fprintf(stderr, "Renderer could not be created! SDL_Error: %s\n", SDL_GetError());SDL_DestroyWindow(window);SDL_Quit();av_frame_free(&frame);avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return -1;}SDL_Texture* texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_YV12, SDL_TEXTUREACCESS_STREAMING,codec_ctx->width, codec_ctx->height);if (!texture) {fprintf(stderr, "Texture could not be created! SDL_Error: %s\n", SDL_GetError());SDL_DestroyRenderer(renderer);SDL_DestroyWindow(window);SDL_Quit();av_frame_free(&frame);avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return -1;}SDL_Rect rect = { 0, 0, codec_ctx->width, codec_ctx->height };sws_ctx = sws_getContext(codec_ctx->width, codec_ctx->height, codec_ctx->pix_fmt,codec_ctx->width, codec_ctx->height, AV_PIX_FMT_YUV420P, SWS_BICUBIC, NULL, NULL, NULL);int64_t start_time = av_gettime();AVPacket pkt;while (av_read_frame(fmt_ctx, &pkt) >= 0) {if (pkt.stream_index == video_stream_idx) {ret = avcodec_send_packet(codec_ctx, &pkt);if (ret < 0) {handle_ffmpeg_error(ret, "send data decoder error.");av_packet_unref(&pkt);continue;}while (ret >= 0) {ret = avcodec_receive_frame(codec_ctx, frame);if (ret == 0) {int64_t pts = frame->pts;if (pts == AV_NOPTS_VALUE) {pts = av_rescale_q(pkt.dts, fmt_ctx->streams[video_stream_idx]->time_base, { 1, 1000000 });}else {pts = av_rescale_q(frame->pts, fmt_ctx->streams[video_stream_idx]->time_base, { 1, 1000000 });}int64_t now = av_gettime();if (pts > now - start_time) {SDL_Delay((pts - (now - start_time)) / 1000);}SDL_UpdateYUVTexture(texture, &rect,frame->data[0], frame->linesize[0],frame->data[1], frame->linesize[1],frame->data[2], frame->linesize[2]);SDL_RenderClear(renderer);SDL_RenderCopy(renderer, texture, NULL, NULL);SDL_RenderPresent(renderer);}else if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {break;}}}av_packet_unref(&pkt);SDL_Event event;while (SDL_PollEvent(&event)) {if (event.type == SDL_QUIT) {goto cleanup;}}}cleanup:sws_freeContext(sws_ctx);SDL_DestroyTexture(texture);SDL_DestroyRenderer(renderer);SDL_DestroyWindow(window);SDL_Quit();av_frame_free(&frame);avcodec_free_context(&codec_ctx);avformat_close_input(&fmt_ctx);return 0;
}

在这里插入图片描述

七、总结与展望

通过本文的详细介绍和代码示例,我们深入了解了视频播放器的开发过程。从视频播放的基本原理,到使用FFmpeg库进行视频文件的解封装、解码,再到利用SDL库进行窗口创建、图形渲染和事件处理,每一个环节都紧密相扣,共同构成了一个完整的视频播放系统。

然而,这只是一个简单的视频播放器示例,实际应用中的视频播放器还需要具备更多的功能和优化。例如,支持更多的视频和音频格式、实现音频的播放和同步、添加播放控制功能(如暂停、快进、快退等)、优化性能以适应不同的硬件环境等。


http://www.ppmy.cn/server/156808.html

相关文章

【Java基础】Stream流、文件File相关操作,IO的含义与运用

1. Java 流(Stream)、文件(File)和IO Java.io 包几乎包含了所有操作输入、输出需要的类。所有这些流类代表了输入源和输出目标。Java.io 包中的流支持很多种格式&#xff0c;比如&#xff1a;基本类型、对象、本地化字符集等等。 一个流可以理解为一个数据的序列。 输入流表…

碰一碰发视频的剪辑功能开发的细节源码搭建,支持OEM

在短视频盛行的今天&#xff0c;为碰一碰发视频增添剪辑功能&#xff0c;能极大提升用户创作的灵活性与趣味性。下面将详细阐述这一功能从技术选型到源码搭建的全过程。 一、技术选型 前端 框架&#xff1a;选择 React 作为前端框架&#xff0c;其基于组件化的开发模式&#x…

对安全的认知

上班摸鱼&#xff0c;随性写的&#xff0c;原来对安全的看法真的很窄&#xff0c;就觉得网络安全&#xff0c;无非是攻防和研究&#xff0c;就相当于觉得数学是代数和几何&#xff0c;确实是无知和片面的&#xff0c;甲方和乙方&#xff0c;对于安全的定义也是不一样的&#xf…

指代消解:自然语言处理中的核心任务与技术进展

目录 前言1. 指代消解的基本概念与分类1.1 回指与共指 2. 指代消解的技术方法2.1 端到端指代消解2.2 高阶推理模型2.3 基于BERT的模型 3. 事件共指消解&#xff1a;跨文档的挑战与进展3.1 联合模型3.2 语义嵌入模型&#xff08;EPASE&#xff09; 4. 应用场景与前景展望4.1 关键…

【嵌入式硬件】直流电机驱动相关

项目场景&#xff1a; 驱动履带车&#xff08;双直流电机&#xff09;前进、后退、转弯 问题描述 电机驱动MOS管烧毁 电机驱动采用IR2104STRH1R403NL的H桥方案&#xff08;这是修改之后的图&#xff09; 原因分析&#xff1a; 1.主要原因是4路PWM没有限幅&#xff0c;修改…

如何查看服务器上的MySQL/Redis等系统服务状态和列表

如果呢你知道系统服务名称&#xff0c;要看状态很简单&#xff1a; systemctl status server-name 比如 systemctl status nginxsystemctl status redis # 等 这是一个nginx的示例&#xff1a; 那问题是 当你不知道服务名称时该怎么办。举个例子&#xff0c;比如mysql在启动…

css的明确性优先级,可提高特异性以提高优先级

CSS明确性优先级的**&#xff08;0&#xff0c;0&#xff0c;0&#xff09;规则**是一种计算和比较选择器优先级的方法&#xff0c;通过将选择器的不同部分对应到三个数字位来确定优先级。具体如下&#xff1a; 规则含义 • 百位&#xff08;0&#xff09;&#xff1a;表示选…

2024年, Milvus 社区的那些事

随着跨年钟声响起&#xff0c;2024 年告一段落。这一年&#xff0c;Milvus GitHub Stars 正式突破 3 万大关&#xff0c;Docker 下载量突破6700w 次&#xff0c;达到一个新的里程碑&#xff0c;在开源向量数据库领域继续引领前行。在这遥遥领先的数据背后&#xff0c;不妨让我们…