基于 FFMPEG 的视频编码 源码(libavcodec,C++ Qt)

基于 FFMPEG 的视频编码 源码(libavcodec,C++ Qt)

昨晚把源代码好好整理了一下,加入了视频时间限制功能。源码放这里,大家随便用。
关于代码的解释可以看我另一篇博客:
基于 FFMPEG 的视频编码(libavcodec ,致敬雷霄骅)

首先是头文件:

/****************************************************************************
** file: VideoRecorder.h
** brief: 利用 ffmpeg 实现视频录制
** Copyright (C) LiYuan
** Author: LiYuan
** E-Mail: 18069211#qq(.)com
** Version 1.0.1
** Last modified: 2021.12.28
** Modified By: LiYuan
**
** Permission is hereby granted, free of charge, to any person obtaining a copy
** of this software and associated documentation files (the "Software"), to deal
** in the Software without restriction, including without limitation the rights
** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
** copies of the Software, and to permit persons to whom the Software is
** furnished to do so, subject to the following conditions:
**
** The above copyright notice and this permission notice shall be included in
** all copies or substantial portions of the Software.
**
** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
** THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
** THE SOFTWARE.
****************************************************************************/

#ifndef VIDEORECORDER_H
#define VIDEORECORDER_H

#include <QObject>
#include <QTime>
#include <QTimer>
#include <QSize>
#include <QImage>
#include <QQueue>

extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
#include <libswscale/swscale.h>
#include <libavutil/opt.h>
#include <libavutil/imgutils.h>
}

namespace Qly {

class VideoRecorder : public QObject
{
    Q_OBJECT
public:
    explicit VideoRecorder(QObject *parent = nullptr);
    ~VideoRecorder();

    void setMaxRecordTime(int ms) {m_timeout = ms * 1000;}
    /**
     * @brief setAVCodecID 设置编码类型。默认是 MPEG4
     * @param id
     */
    void setAVCodecID(AVCodecID id);

    /**
     * @brief setTimeBase 设置视频文件的time base, 默认是 1/1000。 也就是 1ms 为基本单位。
     * @param timebase
     */
    void setTimeBase(AVRational timebase) {m_time_base = timebase;}

    /**
     * @brief openFile 建立视频文件
     * @param url
     * @return
     */
    int openFile(QString url);


    int errorcode() const {return m_errorcode;}

public slots:
    /**
    * @brief close 关闭视频文件
    * @return
    */
    bool close();
    /**
     * @brief setImage 将图像插入到视频中,以当前时间自动计算 pts
     * @param image
     * @return
     */
    bool setImage(const QImage &image);
    /**
     * @brief setImage 将图像插入到视频中
     * @param image
     * @param pts 时间戳,以 time base 为基本单位。第一张图像默认 pts 为 0。 如果 pts = -1 则根据当前时间自动计算 pts.
     * @return
     */
    bool setImage(const QImage &image, int pts);

private slots:

    void timeout();

protected:
    int writeHeader();
    int writeTrailer();
    bool writeFrame(const AVFrame *m_pFrame);
    int initFile(AVCodecID codecID, QSize size);
    void initStreamParameters(AVStream *stream);
    void buildFrameFromImage(AVFrame *m_pFrame, const QImage &image, int pts);

    AVFormatContext *m_pFormatCtx = nullptr;
    const AVCodec *m_pCodec = nullptr;
    AVCodecContext *m_pCodecCtx = nullptr;
    AVFrame *m_pFrame = nullptr;
    AVPacket *m_pPacket = nullptr;

    AVCodecID m_codecID = AV_CODEC_ID_MPEG4;
    AVPixelFormat m_format = AV_PIX_FMT_YUV420P;
    AVRational m_time_base = {1, 1000};

    int64_t m_bit_rate = 10000000;
    int m_width = 0;
    int m_height = 0;
    QString m_url;
private:
    int m_errorcode = 0;
    bool m_recording = false;
    QTime m_startTime;
    QTimer m_timer;
    int m_timeout = 1000 * 1800; //默认半个小时
};

}


#endif // VIDEORECORDER_H

实现代码如下:

/****************************************************************************
** file: VideoRecorder.cpp
** brief: 利用 ffmpeg 实现视频录制
** Copyright (C) LiYuan
** Author: LiYuan
** E-Mail: 18069211#qq(.)com
** Version 1.0.1
** Last modified: 2021.12.28
** Modified By: LiYuan
**
** Permission is hereby granted, free of charge, to any person obtaining a copy
** of this software and associated documentation files (the "Software"), to deal
** in the Software without restriction, including without limitation the rights
** to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
** copies of the Software, and to permit persons to whom the Software is
** furnished to do so, subject to the following conditions:
**
** The above copyright notice and this permission notice shall be included in
** all copies or substantial portions of the Software.
**
** THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
** IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
** FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
** THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
** LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
** OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
** THE SOFTWARE.
****************************************************************************/

#include <QDebug>
#include "VideoRecorder.h"

namespace Qly {

static enum AVPixelFormat toAVPixelFormat(QImage::Format format)
{
    switch (format) {
    case QImage::Format_Invalid:
        return AV_PIX_FMT_NONE;
    case QImage::Format_Mono:
        return AV_PIX_FMT_MONOBLACK;
    case QImage::Format_MonoLSB:
        return AV_PIX_FMT_NONE;
    case QImage::Format_Indexed8:
        return AV_PIX_FMT_PAL8;
    case QImage::Format_Alpha8:
    case QImage::Format_Grayscale8:
        return AV_PIX_FMT_GRAY8;
    case QImage::Format_Grayscale16:
        return AV_PIX_FMT_GRAY16LE;
    case QImage::Format_RGB32:
    case QImage::Format_ARGB32:
    case QImage::Format_ARGB32_Premultiplied:
        //return AV_PIX_FMT_BGRA;
        return AV_PIX_FMT_BGR0;
    case QImage::Format_RGB16:
    case QImage::Format_ARGB8565_Premultiplied:
        return AV_PIX_FMT_RGB565LE;
    case QImage::Format_RGB666:
    case QImage::Format_ARGB6666_Premultiplied:
        return AV_PIX_FMT_NONE;
    case QImage::Format_RGB555:
    case QImage::Format_ARGB8555_Premultiplied:
        return AV_PIX_FMT_BGR555LE;
    case QImage::Format_RGB888:
        return AV_PIX_FMT_RGB24;
    case QImage::Format_RGB444:
    case QImage::Format_ARGB4444_Premultiplied:
        return AV_PIX_FMT_RGB444LE;
    case QImage::Format_RGBX8888:
    case QImage::Format_RGBA8888:
    case QImage::Format_RGBA8888_Premultiplied:
        return AV_PIX_FMT_RGBA;
    case QImage::Format_BGR30:
    case QImage::Format_A2BGR30_Premultiplied:
    case QImage::Format_RGB30:
    case QImage::Format_A2RGB30_Premultiplied:
        return AV_PIX_FMT_NONE;
    case QImage::Format_RGBX64:
    case QImage::Format_RGBA64:
    case QImage::Format_RGBA64_Premultiplied:
        return AV_PIX_FMT_RGBA64LE;
    case QImage::Format_BGR888:
        return AV_PIX_FMT_BGR24;
    default:
        return AV_PIX_FMT_NONE;
    }
    return AV_PIX_FMT_NONE;
}

VideoRecorder::VideoRecorder(QObject *parent) : QObject(parent)
{
    m_pFormatCtx = nullptr;
    m_pPacket = av_packet_alloc();
    if(!m_pPacket)
    {
        qWarning() << "VideoRecorder::VideoRecorder av_packet_alloc failed.";
    }
    m_pFrame = av_frame_alloc();
    if (!m_pFrame)
    {
        qWarning() << "VideoRecorder::VideoRecorder av_frame_alloc failed.";
    }
}

VideoRecorder::~VideoRecorder()
{
    avcodec_free_context(&m_pCodecCtx);
    av_frame_free(&m_pFrame);
    av_packet_free(&m_pPacket);
    if(m_pFormatCtx)
    {
        avformat_free_context(m_pFormatCtx);
    }
}

int VideoRecorder::openFile(QString url)
{
    m_startTime = QTime(); //将 m_startTime 复原到原始状态
    if(url.isNull() || url.isEmpty())
    {
        qWarning() << "VideoRecorder::openFile failed, url is Invalid(empty)";
        return -1;
    }
    m_url = url;
    if(m_pFormatCtx)
    {
        avformat_free_context(m_pFormatCtx);
    }
    m_errorcode = avformat_alloc_output_context2(&m_pFormatCtx, nullptr,
                                               nullptr,
                                               url.toLocal8Bit().constData());
    if(m_errorcode < 0)
    {
        qWarning() << "In VideoRecorder::openFile avformat_alloc_output_context2 failed";
        return -2;
    }
    qDebug() << "avformat_alloc_output_context2 success";
    if (!(m_pFormatCtx->flags & AVFMT_NOFILE))
    {
        m_errorcode = avio_open(&m_pFormatCtx->pb, m_url.toLocal8Bit().constData(), AVIO_FLAG_READ_WRITE);
        if(m_errorcode < 0)
        {
            qWarning() << "in VideoRecorder::openFile avio_open failed";
            return -3;
        }
    }
    qDebug() << "avio_open success";
    m_recording = true;
    return 0;
}

void VideoRecorder::initStreamParameters(AVStream * stream)
{
    stream->time_base.den = m_time_base.den;
    stream->time_base.num = m_time_base.num;
    stream->id = m_pFormatCtx->nb_streams -1;
    stream->index = m_pFormatCtx->nb_streams -1;
    stream->codecpar->codec_tag = 0;
    stream->codecpar->codec_type = m_pCodec->type;
    stream->codecpar->codec_id = m_pCodec->id;
    stream->codecpar->format = m_format;
    stream->codecpar->width = m_width;
    stream->codecpar->height = m_height;
    stream->codecpar->bit_rate = m_bit_rate;
}

int VideoRecorder::initFile(AVCodecID codecID, QSize size)
{
    qDebug() << "IN VideoRecorder::initFile";
    m_width = size.width();
    m_height = size.height();
    m_codecID = codecID;
    m_pCodec = avcodec_find_encoder(codecID);

    if (!m_pCodec)
    {
        qWarning() << "VideoRecorder::initFile avcodec_find_encoder failed.";
        return -2;
    }
    qDebug() << "avcodec_find_encoder success, codecID = " << codecID ;
    AVStream *pStream = avformat_new_stream(m_pFormatCtx, m_pCodec);
    if(pStream == nullptr)
    {
        qWarning() << "VideoRecorder::initFile avformat_new_stream failed.";
        return -3;
    }
    qDebug() << "avformat_new_stream success";

    initStreamParameters(pStream);
    //m_pCodecCtx = pStream->codec;

    qDebug() << "initStreamParameters success";
    if(m_pCodecCtx)
    {
        qDebug() << "avcodec_free_context";
        avcodec_free_context(&m_pCodecCtx);
    }

    qDebug() << "m_pCodecCtx = " << m_pCodecCtx;
    m_pCodecCtx = avcodec_alloc_context3(m_pCodec);
    if(!m_pCodecCtx)
    {
       qWarning() << "VideoRecorder::initFile avcodec_alloc_context3 failed.";
       return -4;
    }
    qDebug() << "avcodec_alloc_context3 success";
    m_pCodecCtx->codec_id = m_pCodec->id;
    m_pCodecCtx->time_base = pStream->time_base;
    m_pCodecCtx->gop_size = 10;
    m_pCodecCtx->max_b_frames = 0;

    //qDebug() << "max_b_frames";

    if (codecID == AV_CODEC_ID_H264)
    {
     av_opt_set(m_pCodecCtx->priv_data, "preset", "fast", 0);
     //av_opt_set(pCodecCtx->priv_data, "tune", "zerolatency", 0);
     //av_opt_set(pCodecCtx->priv_data, "profile", "main", 0);

    }
    else if(codecID == AV_CODEC_ID_H265)
    {
     av_opt_set(m_pCodecCtx->priv_data, "preset", "fast", 0);
     //av_opt_set(pCodecCtx->priv_data, "tune", "zerolatency", 0);
     //av_opt_set(pCodecCtx->priv_data, "profile", "main", 0);
    }

    qDebug() << "av_opt_set";
    /* Some formats want stream headers to be separate. */
    if (m_pFormatCtx->oformat->flags & AVFMT_GLOBALHEADER)
    {
     m_pFormatCtx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;
    }

    avcodec_parameters_to_context(m_pCodecCtx, pStream->codecpar);
    m_errorcode = avcodec_open2(m_pCodecCtx, m_pCodec, nullptr);
    if(m_errorcode < 0)
    {
        qWarning() << "VideoRecorder::initFile avcodec_open2 failed.";
        return -5;
    }
    qDebug() << "avcodec_open2 success";
    m_pFrame->format = (int)m_pCodecCtx->pix_fmt;
    m_pFrame->width  = m_pCodecCtx->width;
    m_pFrame->height = m_pCodecCtx->height;

    if( av_frame_get_buffer(m_pFrame, 0) < 0 )
    {
        qWarning() << "VideoRecorder::initFile av_frame_get_buffer() failed.";
        return -6;
    }
     qDebug() << "av_frame_get_buffer success";
    return 0;
}

void VideoRecorder::buildFrameFromImage(AVFrame *pFrame, const QImage &image, int pts)
{
    //qDebug() << "IN VideoRecorder::buildFrameFromImage";
    /* make sure the frame data is writable */
    if (av_frame_make_writable(pFrame) < 0)
    {
        qWarning() << "in VideoRecorder::buildFrameFromImage av_frame_make_writable(pFrame) failed";
        return;
    }

    int width = image.width();
    int height = image.height();
    AVPixelFormat imgFmt = toAVPixelFormat(image.format());
    SwsContext * pContext = sws_getContext(width, height, imgFmt,
                                           width, height, (AVPixelFormat)pFrame->format, SWS_POINT, nullptr, nullptr, nullptr);
    if(!pContext) return;

    const uint8_t *in_data[1];
    int in_linesize[1];

    in_data[0] = image.bits();
    in_linesize[0] = image.bytesPerLine();

    sws_scale(pContext, in_data, in_linesize, 0, height,
              pFrame->data, pFrame->linesize);
    sws_freeContext(pContext);

    pFrame->pts = pts;
}

bool VideoRecorder::setImage(const QImage &image, int pts)
{
    //qDebug() << "IN VideoRecorder::setImage";
    if(!m_recording)
    {
        qDebug() << "in VideoRecorder::setImage m_recording = false";
        return false;
    }
    QTime t = QTime::currentTime();
    if( pts < 0 ) // 说明这时要用真实的时间来做为 pts
    {
        if(m_startTime.isNull())
        {
            m_startTime = t; // 说明这是第一帧。需要初始化起始时间。
        }
        int oldpts = m_startTime.msecsTo(t);

        pts = av_rescale_q_rnd(oldpts, AVRational({1, 1000}), m_time_base, AV_ROUND_NEAR_INF);
        //qDebug() << "oldpts = " << oldpts << ", pts = " << pts;
    }
    //qDebug() << "pts = " << pts;
    if(m_width == 0) // 说明这是第一个帧
    {
        initFile(m_codecID, image.size());
        writeHeader();
        av_dump_format(m_pFormatCtx, 0, m_url.toLocal8Bit().constData(), true);
        m_timer.singleShot(m_timeout, this, SLOT(timeout()));
    }
    buildFrameFromImage(m_pFrame, image, pts);
    return writeFrame(m_pFrame);
}

void VideoRecorder::timeout()
{
    close();
}

void VideoRecorder::setAVCodecID(AVCodecID id)
{
    m_codecID = id;
    m_pCodec = avcodec_find_encoder(id);
    if(m_pCodec)
    {
        const enum AVPixelFormat * pFormat = m_pCodec->pix_fmts;
        if(pFormat)
        {
            while (*pFormat != AV_PIX_FMT_NONE)
            {
                if(*pFormat == m_format)
                {
                    return;
                }
                pFormat ++;
            }
            // 到这里说明 m_format 不在当前 codec 支持的 format 里
            pFormat = m_pCodec->pix_fmts;
            m_format = *pFormat; // 默认使用 codec 支持的第一个 format
        }
    }
}

int VideoRecorder::writeHeader()
{
    m_errorcode = avformat_write_header(m_pFormatCtx, nullptr);
    if(m_errorcode < 0)
    {
        qWarning() << "in VideoRecorder::writeHeader avformat_write_header failed";
        return -2;
    }
    return 0;
}

int VideoRecorder::writeTrailer()
{
    m_errorcode = av_write_trailer(m_pFormatCtx);
    if(m_errorcode < 0)
    {
        qWarning() << "in VideoRecorder::writeTrailer av_write_trailer failed";
        return -1;
    }
    return 0;
}

bool VideoRecorder::writeFrame(const AVFrame *pFrame)
{
    //qDebug() << "IN VideoRecorder::writeFrame";
    m_errorcode = avcodec_send_frame(m_pCodecCtx, pFrame);
    if(m_errorcode < 0)
    {
        qWarning() << "in VideoRecorder::writeFrame avcodec_send_frame failed";
        return false;
    }

    while (m_errorcode >= 0)
    {
        m_errorcode = avcodec_receive_packet(m_pCodecCtx, m_pPacket);
        if (m_errorcode == AVERROR(EAGAIN) || m_errorcode == AVERROR_EOF)
        {
            return true;
        }
        else if (m_errorcode < 0)
        {
            qWarning() << "in VideoRecorder::writeFrame avcodec_receive_packet failed";
            return false;
        }
        m_pPacket->stream_index = 0;
        AVRational out_timebase = m_pFormatCtx->streams[0]->time_base;

        m_pPacket->pts = av_rescale_q_rnd(m_pPacket->pts, m_time_base, out_timebase, (AVRounding)(AV_ROUND_NEAR_INF|AV_ROUND_PASS_MINMAX));
        m_pPacket->dts = av_rescale_q_rnd(m_pPacket->dts, m_time_base, out_timebase,  (AVRounding)(AV_ROUND_NEAR_INF|AV_ROUND_PASS_MINMAX));
        m_pPacket->duration = av_rescale_q(m_pPacket->duration, m_time_base, out_timebase);
        m_pPacket->pos = -1;

        m_errorcode = av_interleaved_write_frame(m_pFormatCtx, m_pPacket);
        if (m_errorcode < 0)
        {
            qWarning() << "in VideoRecorder::writeFrame av_interleaved_write_frame failed";
            return false;
        }
        av_packet_unref(m_pPacket);
    }
    return true;
}

bool VideoRecorder::close()
{
    m_recording = false;
    m_timer.stop();
    writeFrame(nullptr);
    writeTrailer();
    if (m_pFormatCtx && !(m_pFormatCtx->flags & AVFMT_NOFILE))
    {
        m_errorcode = avio_closep(&m_pFormatCtx->pb);
    }

    m_width = 0;
    m_height = 0;
    return true;
}
}//namespace Qly

下面是一个例子代码:

#include <QCoreApplication>
#include <QPainter>
#include <QDebug>
#include "VideoRecorder.h"

void paint(QImage &image, int i)
{
    QPainter p(&image);
    image.fill(Qt::white);
    p.drawPie(50 + i, 100, 100, 100, 0, 16*360);

}

int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);
    Qly::VideoRecorder writer;
    writer.setAVCodecID(AV_CODEC_ID_MPEG4);
    writer.setTimeBase(AVRational({1, 25}));
    writer.openFile("D:\\MPEG4.avi");
    QImage image(QSize(1024, 768), QImage::Format_RGB32);
    for(int i = 0; i < 1500; i++)
    {
        paint(image, i);
        writer.setImage(image, i * 1);
    }
    writer.close();
    qDebug() << "finish";
    return a.exec();
}

这个代码需要调用 ffmpeg 的几个库。如果是 用 vcpkg 编译的库。那么在 Qt pro 文件中可以加入这么一段:

VCPKG_DIR = C:/vcpkg/

win32{
        contains(QT_ARCH, i386) {
        message("ffmpeg support code 32-bit")
                INCLUDEPATH += $$VCPKG_DIR/installed/x86-windows/include
                DEPENDPATH += $$VCPKG_DIR/installed/x86-windows/bin

        CONFIG(release, debug|release): LIBS += -L$$VCPKG_DIR/installed/x86-windows/lib/ \
                                                        -lavcodec \
                                                        -lavdevice \
                                                        -lavfilter \
                                                        -lavformat \
                                                        -lavutil \
                                                        -lswresample \
                                                        -lswscale

                else:CONFIG(debug, debug|release): LIBS += -L$$VCPKG_DIR/installed/x86-windows/debug/lib/ \
                                                        -lavcodec \
                                                        -lavdevice \
                                                        -lavfilter \
                                                        -lavformat \
                                                        -lavutil \
                                                        -lswresample \
                                                        -lswscale
    } else {
        message("ffmpeg support code 64-bit")
                INCLUDEPATH += $$VCPKG_DIR/installed/x64-windows/include
                DEPENDPATH +=  $$VCPKG_DIR/installed/x64-windows/bin
        CONFIG(release, debug|release): LIBS += -L$$VCPKG_DIR/installed/x64-windows/lib/ \
                                                        -lavcodec \
                                                        -lavdevice \
                                                        -lavfilter \
                                                        -lavformat \
                                                        -lavutil \
                                                        -lswresample \
                                                        -lswscale

                else:CONFIG(debug, debug|release): LIBS += -L$$VCPKG_DIR/installed/x64-windows/debug/lib/ \
                                                        -lavcodec \
                                                        -lavdevice \
                                                        -lavfilter \
                                                        -lavformat \
                                                        -lavutil \
                                                        -lswresample \
                                                        -lswscale
        # message($$LIBS)
    }
}
MPEG-2视频编解码源代码 This directory contains our implementation of an ISO/IEC DIS 13818-2 codec. It converts uncompressed video frames into MPEG-1 and MPEG-2 video coded bitstream sequences, and vice versa. The files mpeg2enc.doc and mpeg2dec.doc in the doc/ directory contain further information about the codec. The directory verify/ contains a small set of verification pictures, a small bitstream, and Unix shell script to automatically test the output of the encoder and decoder. A precompiled version of the programs for Win32s (Windows NT/95) will be made available later date, although it is trivial to make a console application from the encoder and decoder with most Win32s compilers (such as Microsoft Visual C++). Subdirectories src/mpeg2enc and src/mpeg2dec contain the source code for the encoder and decoder, subdirectory par/ contains a couple of example encoder parameter files for 25 and 30 frames/sec MPEG-2 and MPEG-1 video. Summary of changes since July 4, 1994 release: This is only the second official release of our MPEG-2 video software. Only minor bug corrections have been added to the encoder. We still do not implement scalable encoding, as this is mostly useful only for academic research. The decoder has been updated to meet the final MPEG specification, although the old decoder will still reconstruct Main Profile and MPEG-1 bitstreams just fine. The current decoder implements the most important case of Spatial scalability, as well as SNR and Data Partitioning. Temporal scalability is not implemented.
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值