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

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

    • 前言
    • 一、视频播放器开发的基础
      • [1.1 视频播放原理](#1.1 视频播放原理)
      • [1.2 开发所需的库](#1.2 开发所需的库)
    • 二、FFmpeg库详解
      • [2.1 FFmpeg库的组成](#2.1 FFmpeg库的组成)
      • [2.2 关键数据结构](#2.2 关键数据结构)
      • [2.3 打开视频文件并获取流信息](#2.3 打开视频文件并获取流信息)
      • [2.4 查找视频流和解码器](#2.4 查找视频流和解码器)
      • [2.5 初始化解码器](#2.5 初始化解码器)
    • 三、SDL库详解
      • [3.1 SDL库的功能](#3.1 SDL库的功能)
      • [3.2 初始化SDL](#3.2 初始化SDL)
      • [3.3 创建窗口、渲染器和纹理](#3.3 创建窗口、渲染器和纹理)
      • [3.4 事件处理](#3.4 事件处理)
    • 四、视频播放流程
      • [4.1 读取和解码视频帧](#4.1 读取和解码视频帧)
      • [4.2 时间同步](#4.2 时间同步)
      • [4.3 图像格式转换与渲染](#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 打开视频文件并获取流信息

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

cpp 复制代码
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 查找视频流和解码器

cpp 复制代码
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 初始化解码器

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

cpp 复制代码
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之前,需要先对其进行初始化:

cpp 复制代码
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 创建窗口、渲染器和纹理

cpp 复制代码
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 事件处理

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

cpp 复制代码
SDL_Event event;
while (SDL_PollEvent(&event)) {
    if (event.type == SDL_QUIT) {
        goto cleanup;
    }
}

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

四、视频播放流程

4.1 读取和解码视频帧

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

cpp 复制代码
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 时间同步

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

cpp 复制代码
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 图像格式转换与渲染

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

cpp 复制代码
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函数将渲染器的内容显示在窗口上。

五、资源清理

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

cpp 复制代码
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库资源、视频帧、解码器上下文以及视频文件格式上下文。

六、完整代码示例

cpp 复制代码
#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库进行窗口创建、图形渲染和事件处理,每一个环节都紧密相扣,共同构成了一个完整的视频播放系统。

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

相关推荐
丘上人1 小时前
ffmpeg 命令行 重置音频或视频的时间戳
ffmpeg·音视频
伊织code5 小时前
Decord - 深度学习视频加载器
人工智能·深度学习·ai·音视频·视频·加载·decord
喜欢小苹果的码农5 小时前
javaCV音频剪切
java·音视频
我真不会起名字啊6 小时前
“深入浅出”系列之FFmpeg:(1)音视频开发基础
ffmpeg·音视频
普通网友6 小时前
AI绘画 Stable Diffusion【进阶篇】:Recolor模型实现头发衣服换色
人工智能·ai作画·stable diffusion·aigc·音视频
飘逸高铁侠9 小时前
使用 whisper和ffmpeg 烧录视频的中英文双语字幕
ffmpeg·whisper·音视频
瘦弱的皮卡丘15 小时前
声音是如何产生的
音视频·音频·音频3a
风雅GW15 小时前
本地LLM部署--Open WebUI(多媒体工具FFMPEG作用)
gpt·ffmpeg·agi
丘上人15 小时前
下载b站高清视频
音视频
置酒天晴20 小时前
js -音频变音(听不出说话的人是谁)
开发语言·javascript·音视频