Liudmila Loglisci & Davide Caci (专辑)

#include "FFMpegVideo.h"

#ifdef USE_FFMPEG

extern "C"
{
#include <libswscale/swscale.h>
}

#include <QNetworkReply>
#include <QNetworkRequest>
#include <QEventLoop>
#include <QFileInfo>
#include <QMutexLocker>
#include <QDebug>
#include <stdexcept>
#include <iostream>
#include <cassert>

using namespace std;

// Translated to C++ by Christopher Bruns May 2012
// from ffmeg_adapt.c in whisk package by Nathan Clack, Mark Bolstadt, Michael Meeuwisse

QMutex FFMpegVideo::mutex;

// Avoid link error on some macs
#ifdef __APPLE__
extern "C" {
#include <stdlib.h>
#include <errno.h>
// #include "compiler/compiler.h"

/*
 * Darwin doesn't have posix_memalign(), provide a private
 * weak alternative
 */
    /*
int __weak posix_memalign(void **ptr, size_t align, size_t size)
{
       if (*ptr)
               return 0;

       return ENOMEM;
}
*/
}
#endif

// Custom read function so FFMPEG does not need to read from a local file by name.
// But rather from a stream derived from a URL or whatever.
extern "C" {

int readFunction(void* opaque, uint8_t* buf, int buf_size)
{
    QIODevice* stream = (QIODevice*)opaque;
    int numBytes = stream->read((char*)buf, buf_size);
    return numBytes;
}

// http://cdry.wordpress.com/2009/09/09/using-custom-io-callbacks-with-ffmpeg/
int64_t seekFunction(void* opaque, int64_t offset, int whence)
{
    QIODevice* stream = (QIODevice*)opaque;
    if (stream == NULL)
        return -1;
    else if (whence == AVSEEK_SIZE)
        return -1; // "size of my handle in bytes"
    else if (stream->isSequential())
        return -1; // cannot seek a sequential stream
    else if (whence == SEEK_CUR) { // relative to start of file
        if (! stream->seek(stream->pos() + offset) )
            return -1;
    }
    else if (whence == SEEK_END) { // relative to end of file
        assert(offset < 0);
        if (! stream->seek(stream->size() + offset) )
            return -1;
    }
    else if (whence == SEEK_SET) { // relative to start of file
        if (! stream->seek(offset) )
            return -1;
    }
    else {
        assert(false);
    }
    return stream->pos();
}

}


/////////////////////////////
// AVPacketWrapper methods //
/////////////////////////////

class AVPacketWrapper
{
public:
    AVPacketWrapper();
    virtual ~AVPacketWrapper();
    void free();

    AVPacket packet;
};


AVPacketWrapper::AVPacketWrapper()
{
    packet.destruct = NULL;
}

/* virtual */
AVPacketWrapper::~AVPacketWrapper()
{
    free();
}

void AVPacketWrapper::free()
{
    av_free_packet(&packet);
}


/////////////////////////
// FFMpegVideo methods //
/////////////////////////

FFMpegVideo::FFMpegVideo(PixelFormat pixelFormat)
    : isOpen(false)
{
    initialize();
    format = pixelFormat;
}

FFMpegVideo::FFMpegVideo(QUrl url, PixelFormat pixelFormat)
    : isOpen(false)
{
    QMutexLocker lock(&FFMpegVideo::mutex);
    initialize();
    format = pixelFormat;
    isOpen = open(url, pixelFormat);
}

/* virtual */
FFMpegVideo::~FFMpegVideo()
{
    QMutexLocker lock(&FFMpegVideo::mutex);
    if (NULL != Sctx) {
        sws_freeContext(Sctx);
        Sctx = NULL;
    }
    if (NULL != pRaw) {
        av_free(pRaw);
        pRaw = NULL;
    }
    if (NULL != pFrameRGB) {
        av_free(pFrameRGB);
        pFrameRGB = NULL;
    }
    if (NULL != pCtx) {
        avcodec_close(pCtx);
        pCtx = NULL;
    }
    if (NULL != container) {
        avformat_close_input(&container);
        container = NULL;
    }
    if (NULL != buffer) {
        av_free(buffer);
        buffer = NULL;
    }
    if (NULL != blank) {
        av_free(blank);
        blank = NULL;
    }
    /*
    if (NULL != avioContext) {
        av_free(avioContext);
        avioContext = NULL;
    }
    */
    if (reply != NULL) {
        reply->deleteLater();
        reply = NULL;
    }
    // Don't need to free pCodec?
}

bool FFMpegVideo::open(QUrl url, enum PixelFormat formatParam)
{
    if (url.isEmpty())
        return false;

    // Is the movie source a local file?
    if (url.host() == "localhost")
        url.setHost("");
    QString fileName = url.toLocalFile();
    if ( (! fileName.isEmpty())
        && (QFileInfo(fileName).exists()) )
    {
        // return open(fileName, formatParam); // for testing only

        // Yes, the source is a local file
        fileStream.setFileName(fileName);
        // qDebug() << fileName;
        if (! fileStream.open(QIODevice::ReadOnly))
            return false;
        return open(fileStream, fileName, formatParam);
    }

    // ...No, the source is not a local file
    if (url.host() == "")
        url.setHost("localhost");
    fileName = url.path();

    // http://stackoverflow.com/questions/9604633/reading-a-file-located-in-memory-with-libavformat
    // Load from URL
    QEventLoop loop; // for synchronous url fetch http://stackoverflow.com/questions/5486090/qnetworkreply-wait-for-finished
    QObject::connect(&networkManager, SIGNAL(finished(QNetworkReply*)),
            &loop, SLOT(quit()));
    QNetworkRequest request = QNetworkRequest(url);
    // qDebug() << "networkManager" << __FILE__ << __LINE__;
    reply = networkManager.get(request);
    loop.exec();
    if (reply->error() != QNetworkReply::NoError) {
        // qDebug() << reply->error();
        reply->deleteLater();
        reply = NULL;
        return false;
    }
    QIODevice * stream = reply;
    // Mpeg needs seekable device, so create in-memory buffer if necessary
    if (stream->isSequential()) {
        byteArray = stream->readAll();
        fileBuffer.setBuffer(&byteArray);
        fileBuffer.open(QIODevice::ReadOnly);
        if (! fileBuffer.seek(0))
            return false;
        stream = &fileBuffer;
        assert(! stream->isSequential());
    }
    bool result = open(*stream, fileName, formatParam);
    return result;
}

bool FFMpegVideo::open(QIODevice& fileStream, QString& fileName, enum PixelFormat formatParam)
{
    // http://stackoverflow.com/questions/9604633/reading-a-file-located-in-memory-with-libavformat
    // I think AVIOContext is the trick used to redivert the input stream
    ioBuffer = (unsigned char *)av_malloc(ioBufferSize + FF_INPUT_BUFFER_PADDING_SIZE); // can get av_free()ed by libav
    avioContext = avio_alloc_context(ioBuffer, ioBufferSize, 0, (void*)(&fileStream), &readFunction, NULL, &seekFunction);
    container = avformat_alloc_context();
    container->pb = avioContext;

    // Open file, check usability
    std::string fileNameStd = fileName.toStdString();
    if (!avtry( avformat_open_input(&container, fileNameStd.c_str(), NULL, NULL), fileNameStd ))
        return false;
    return openUsingInitializedContainer(formatParam);
}

// file name based method for historical continuity
bool FFMpegVideo::open(QString& fileName, enum PixelFormat formatParam)
{
    // Open file, check usability
    std::string fileNameStd = fileName.toStdString();
    if (!avtry( avformat_open_input(&container, fileNameStd.c_str(), NULL, NULL), fileNameStd ))
        return false;
    return openUsingInitializedContainer(formatParam);
}


bool FFMpegVideo::openUsingInitializedContainer(enum PixelFormat formatParam)
{
    format = formatParam;
    sc = getNumberOfChannels();

    if (!avtry( avformat_find_stream_info(container, NULL), "Cannot find stream information." ))
        return false;
    if (!avtry( videoStream=av_find_best_stream(container, AVMEDIA_TYPE_VIDEO, -1, -1, &pCodec, 0), "Cannot find a video stream." ))
        return false;
    pCtx=container->streams[videoStream]->codec;
    width = pCtx->width;
    height = pCtx->height;
    if (!avtry( avcodec_open2(pCtx, pCodec, NULL), "Cannot open video decoder." ))
        return false;

    /* Frame rate fix for some codecs */
    if( pCtx->time_base.num > 1000 && pCtx->time_base.den == 1 )
        pCtx->time_base.den = 1000;

    /* Compute the total number of frames in the file */
    /* duration is in microsecs */
    numFrames = (int)(( container->duration / (double)AV_TIME_BASE ) * pCtx->time_base.den + 0.5);

    /* Get framebuffers */
    if (! (pRaw = avcodec_alloc_frame()) )
        throw std::runtime_error("");
    if (! (pFrameRGB = avcodec_alloc_frame()) )
        throw std::runtime_error("");

    /* Create data buffer */
    if (format == PIX_FMT_NONE) {
        numBytes = 0;
        buffer = NULL;
        blank = NULL;
        pFrameRGB = NULL;
        Sctx = NULL;
    }
    else {
        numBytes = avpicture_get_size( format, pCtx->width, pCtx->height ); // RGB24 format
        if (! (buffer = (uint8_t*)av_malloc(numBytes + FF_INPUT_BUFFER_PADDING_SIZE)) ) // RGB24 format
            throw std::runtime_error("");
        if (! (blank = (uint8_t*)av_mallocz(avpicture_get_size(pCtx->pix_fmt,width,height))) ) // native codec format
            throw std::runtime_error("");

        /* Init buffers */
        avpicture_fill( (AVPicture * ) pFrameRGB, buffer, format,
                        pCtx->width, pCtx->height );

        /* Init scale & convert */
        if (! (Sctx=sws_getContext(
                pCtx->width,
                pCtx->height,
                pCtx->pix_fmt,
                width,
                height,
                format,
                SWS_POINT, // fastest?
                NULL,NULL,NULL)) )
            throw std::runtime_error("");
    }

    /* Give some info on stderr about the file & stream */
    //dump_format(container, 0, fname, 0);

    previousFrameIndex = -1;
    return true;
}

bool FFMpegVideo::fetchFrame(int targetFrameIndex)
{
    if ((targetFrameIndex < 0) || (targetFrameIndex > numFrames))
        return false;
    if (targetFrameIndex == (previousFrameIndex + 1)) {
        if (! readNextFrame(targetFrameIndex))
            return false;
    }
    else
        if (seekToFrame(targetFrameIndex) < 0)
            return false;
    previousFrameIndex = targetFrameIndex;
    return true;
}

// \returns current frame on success, otherwise -1
int FFMpegVideo::seekToFrame(int targetFrameIndex)
{
    int64_t duration = container->streams[videoStream]->duration;
    int64_t ts = av_rescale(duration,targetFrameIndex,numFrames);
    int64_t tol = av_rescale(duration,1,2*numFrames);
    if ( (targetFrameIndex < 0) || (targetFrameIndex >= numFrames) ) {
        return -1;
    }
    int result = avformat_seek_file( container, //format context
                            videoStream,//stream id
                            0, //min timestamp
                            ts, //target timestamp
                            ts, //max timestamp
                            0); //AVSEEK_FLAG_ANY),//flags
    if (result < 0)
        return -1;

    avcodec_flush_buffers(pCtx);
    if (! readNextFrame(targetFrameIndex))
        return -1;

    return targetFrameIndex;
}

bool FFMpegVideo::readNextFrame(int targetFrameIndex)
{
    AVPacket packet = {0};
    av_init_packet(&packet);
    bool result = readNextFrameWithPacket(targetFrameIndex, packet, pRaw);
    av_free_packet(&packet);
    return result;
}

// WARNING this method can raise an exception
bool FFMpegVideo::readNextFrameWithPacket(int targetFrameIndex, AVPacket& packet, AVFrame* pYuv)
{
    int finished = 0;
    do {
        finished = 0;
        av_free_packet(&packet);
        int result;
        if (!avtry(av_read_frame( container, &packet ), "Failed to read frame"))
            return false; // !!NOTE: see docs on packet.convergence_duration for proper seeking
        if( packet.stream_index != videoStream ) /* Is it what we're trying to parse? */
            continue;
        if (!avtry(avcodec_decode_video2( pCtx, pYuv, &finished, &packet ), "Failed to decode video"))
            return false;
        // handle odd cases and debug
        if((pCtx->codec_id==CODEC_ID_RAWVIDEO) && !finished)
        {
            avpicture_fill( (AVPicture * ) pYuv, blank, pCtx->pix_fmt,width, height ); // set to blank frame
            finished = 1;
        }
#if 0 // very useful for debugging
        cout << "Packet - pts:" << (int)packet.pts;
        cout << " dts:" << (int)packet.dts;
        cout << " - flag: " << packet.flags;
        cout << " - finished: " << finished;
        cout << " - Frame pts:" << (int)pYuv->pts;
        cout << " " << (int)pYuv->best_effort_timestamp;
        cout << endl;
        /* printf("Packet - pts:%5d dts:%5d (%5d) - flag: %1d - finished: %3d - Frame pts:%5d %5d\n",
               (int)packet.pts,(int)packet.dts,
               packet.flags,finished,
               (int)pYuv->pts,(int)pYuv->best_effort_timestamp); */
#endif
        if(!finished) {
            if (packet.pts == AV_NOPTS_VALUE)
                throw std::runtime_error("");
            if (packet.size == 0) // packet.size==0 usually means EOF
                break;
        }
    } while ( (!finished) || (pYuv->best_effort_timestamp < targetFrameIndex));

    av_free_packet(&packet);

    if (format != PIX_FMT_NONE) {
        sws_scale(Sctx, // sws context
                  pYuv->data, // src slice
                  pYuv->linesize, // src stride
                  0, // src slice origin y
                  pCtx->height, // src slice height
                  pFrameRGB->data, // dst
                  pFrameRGB->linesize ); // dst stride
    }

    previousFrameIndex = targetFrameIndex;
    return true;
}

uint8_t FFMpegVideo::getPixelIntensity(int x, int y, Channel c) const
{
    return *(pFrameRGB->data[0] + y * pFrameRGB->linesize[0] + x * sc + c);
}

int FFMpegVideo::getNumberOfFrames() const { return numFrames; }

int FFMpegVideo::getWidth() const { return width; }

int FFMpegVideo::getHeight() const { return height; }

int FFMpegVideo::getNumberOfChannels() const
{
    switch(format)
    {
    case PIX_FMT_BGRA:
        return 4;
        break;
    case PIX_FMT_RGB24:
        return 3;
        break;
    case PIX_FMT_GRAY8:
        return 1;
        break;
    default:
        return 0;
        break;
    }
    return 0;
}

void FFMpegVideo::initialize()
{
    Sctx = NULL;
    pRaw = NULL;
    pFrameRGB = NULL;
    pCtx = NULL;
    container = NULL;
    buffer = NULL;
    blank = NULL;
    pCodec = NULL;
    format = PIX_FMT_NONE;
    reply = NULL;
    ioBuffer = NULL;
    avioContext = NULL;
    FFMpegVideo::maybeInitFFMpegLib();
}

void FFMpegVideo::maybeInitFFMpegLib()
{
    if (FFMpegVideo::b_is_one_time_inited)
        return;
    av_register_all();
    avcodec_register_all();
    avformat_network_init();
    FFMpegVideo::b_is_one_time_inited = true;
}

bool FFMpegVideo::avtry(int result, const std::string& msg) {
    if ((result < 0) && (result != AVERROR_EOF)) {
        char buf[1024];
        av_strerror(result, buf, sizeof(buf));
        std::string message = std::string("FFMpeg Error: ") + msg + buf;
        qDebug() << QString(message.c_str());
        return false;
    }
    return true;
}

bool FFMpegVideo::b_is_one_time_inited = false;



///////////////////////////
// FFMpegEncoder methods //
///////////////////////////


FFMpegEncoder::FFMpegEncoder(const char * file_name, int width, int height, enum CodecID codec_id)
    : picture_yuv(NULL)
    , picture_rgb(NULL)
    , container(NULL)
{
    if (0 != (width % 2))
        cerr << "WARNING: Video width is not a multiple of 2" << endl;
    if (0 != (height % 2))
        cerr << "WARNING: Video height is not a multiple of 2" << endl;

    FFMpegVideo::maybeInitFFMpegLib();

    container = avformat_alloc_context();
    if (NULL == container)
        throw std::runtime_error("Unable to allocate format context");

    AVOutputFormat * fmt = av_guess_format(NULL, file_name, NULL);
    if (!fmt)
        fmt = av_guess_format("mpeg", NULL, NULL);
    if (!fmt)
        throw std::runtime_error("Unable to deduce video format");
    container->oformat = fmt;

    fmt->video_codec = codec_id;
    // fmt->video_codec = CODEC_ID_H264; // fails to write

    AVStream * video_st = avformat_new_stream(container, NULL);

    pCtx = video_st->codec;
    pCtx->codec_id = fmt->video_codec;
    pCtx->codec_type = AVMEDIA_TYPE_VIDEO;
    // resolution must be a multiple of two
    pCtx->width = width;
    pCtx->height = height;

    // bit_rate determines image quality
    pCtx->bit_rate = width * height * 4; // ?
    // pCtx->qmax = 50; // no effect?

    // "high quality" parameters from http://www.cs.ait.ac.th/~on/mplayer/pl/menc-feat-enc-libavcodec.html
    // vcodec=mpeg4:mbd=2:mv0:trell:v4mv:cbp:last_pred=3:predia=2:dia=2:vmax_b_frames=2:vb_strategy=1:precmp=2:cmp=2:subcmp=2:preme=2:vme=5:naq:qns=2
    if (false) // does not help
    // if (pCtx->codec_id == CODEC_ID_MPEG4)
    {
        pCtx->mb_decision = 2;
        pCtx->last_predictor_count = 3;
        pCtx->pre_dia_size = 2;
        pCtx->dia_size = 2;
        pCtx->max_b_frames = 2;
        pCtx->b_frame_strategy = 2;
        pCtx->trellis = 2;
        pCtx->compression_level = 2;
        pCtx->global_quality = 300;
        pCtx->pre_me = 2;
        pCtx->mv0_threshold = 1;
        // pCtx->quantizer_noise_shaping = 2; // deprecated
        // TODO
    }

    pCtx->time_base = (AVRational){1, 25};
    // pCtx->time_base = (AVRational){1, 10};
    pCtx->gop_size = 12; // emit one intra frame every twelve frames
    // pCtx->max_b_frames = 0;
    pCtx->pix_fmt = PIX_FMT_YUV420P;
    if (fmt->flags & AVFMT_GLOBALHEADER)
        pCtx->flags |= CODEC_FLAG_GLOBAL_HEADER;

    if (pCtx->codec_id == CODEC_ID_H264)
    {
        // http://stackoverflow.com/questions/3553003/encoding-h-264-with-libavcodec-x264
        pCtx->coder_type = 1; // coder = 1
        pCtx->flags|=CODEC_FLAG_LOOP_FILTER; // flags=+loop
        pCtx->me_cmp|= 1; // cmp=+chroma, where CHROMA = 1
        // pCtx->partitions|=X264_PART_I8X8+X264_PART_I4X4+X264_PART_P8X8+X264_PART_B8X8; // partitions=+parti8x8+parti4x4+partp8x8+partb8x8
        pCtx->me_method=ME_HEX; // me_method=hex
        pCtx->me_subpel_quality = 7; // subq=7
        pCtx->me_range = 16; // me_range=16
        pCtx->gop_size = 250; // g=250
        pCtx->keyint_min = 25; // keyint_min=25
        pCtx->scenechange_threshold = 40; // sc_threshold=40
        pCtx->i_quant_factor = 0.71; // i_qfactor=0.71
        pCtx->b_frame_strategy = 1; // b_strategy=1
        pCtx->qcompress = 0.6; // qcomp=0.6
        pCtx->qmin = 10; // qmin=10
        pCtx->qmax = 51; // qmax=51
        pCtx->max_qdiff = 4; // qdiff=4
        pCtx->max_b_frames = 3; // bf=3
        pCtx->refs = 3; // refs=3
        // pCtx->directpred = 1; // directpred=1
        pCtx->trellis = 1; // trellis=1
        // pCtx->flags2|=CODEC_FLAG2_BPYRAMID+CODEC_FLAG2_MIXED_REFS+CODEC_FLAG2_WPRED+CODEC_FLAG2_8X8DCT+CODEC_FLAG2_FASTPSKIP; // flags2=+bpyramid+mixed_refs+wpred+dct8x8+fastpskip
        // pCtx->weighted_p_pred = 2; // wpredp=2
        // libx264-main.ffpreset preset
        // pCtx->flags2|=CODEC_FLAG2_8X8DCT;
        // pCtx->flags2^=CODEC_FLAG2_8X8DCT; // flags2=-dct8x8
    }

    AVCodec * codec = avcodec_find_encoder(pCtx->codec_id);
    if (NULL == codec)
        throw std::runtime_error("Unable to find Mpeg4 codec");
    if (codec->pix_fmts)
        pCtx->pix_fmt = codec->pix_fmts[0];
    {
        QMutexLocker lock(&FFMpegVideo::mutex);
        if (avcodec_open2(pCtx, codec, NULL) < 0)
            throw std::runtime_error("Error opening codec");
    }

    /* Get framebuffers */
    if (! (picture_yuv = avcodec_alloc_frame()) ) // final frame format
        throw std::runtime_error("");
    if (! (picture_rgb = avcodec_alloc_frame()) ) // rgb version I can understand easily
        throw std::runtime_error("");
    /* the image can be allocated by any means and av_image_alloc() is
         * just the most convenient way if av_malloc() is to be used */
    if ( av_image_alloc(picture_yuv->data, picture_yuv->linesize,
                       pCtx->width, pCtx->height, pCtx->pix_fmt, 1) < 0 )
        throw std::runtime_error("Error allocating YUV frame buffer");
    if ( av_image_alloc(picture_rgb->data, picture_rgb->linesize,
                   pCtx->width, pCtx->height, PIX_FMT_RGB24, 1) < 0 )
        throw std::runtime_error("Error allocating RGB frame buffer");

    /* Init scale & convert */
    if (! (Sctx=sws_getContext(
            width,
            height,
            PIX_FMT_RGB24,
            pCtx->width,
            pCtx->height,
            pCtx->pix_fmt,
            SWS_BICUBIC,NULL,NULL,NULL)) )
        throw std::runtime_error("");

    /* open the output file */
    if (!(fmt->flags & AVFMT_NOFILE))
    {
        QMutexLocker lock(&FFMpegVideo::mutex);
        if (avio_open(&container->pb, file_name, AVIO_FLAG_WRITE) < 0)
             throw std::runtime_error("Error opening output video file");
    }
    avformat_write_header(container, NULL);
}

void FFMpegEncoder::setPixelIntensity(int x, int y, int c, uint8_t value)
{
    uint8_t * ptr = picture_rgb->data[0] + y * picture_rgb->linesize[0] + x * 3 + c;
    *ptr = value;
}

void FFMpegEncoder::write_frame()
{
    // convert from RGB24 to YUV
    sws_scale(Sctx, // sws context
              picture_rgb->data, // src slice
              picture_rgb->linesize, // src stride
              0, // src slice origin y
              pCtx->height, // src slice height
              picture_yuv->data, // dst
              picture_yuv->linesize ); // dst stride

    /* encode the image */
    // use non-deprecated avcodec_encode_video2(...)
    AVPacket packet;
    av_init_packet(&packet);
    packet.data = NULL;
    packet.size = 0;

    int got_packet;
    int ret = avcodec_encode_video2(pCtx,
                                    &packet,
                                    picture_yuv,
                                    &got_packet);
    if (ret < 0)
        throw std::runtime_error("Video encoding failed");
    if (got_packet)
    {
        // std::cout << "encoding frame" << std::endl;
        int result = av_write_frame(container, &packet);
        av_destruct_packet(&packet);
    }
}

/* virtual */
FFMpegEncoder::~FFMpegEncoder()
{
    int result = av_write_frame(container, NULL); // flush
    result = av_write_trailer(container);
    {
        QMutexLocker lock(&FFMpegVideo::mutex);
        avio_close(container->pb);
    }
    for (int i = 0; i < container->nb_streams; ++i)
        av_freep(container->streams[i]);
    av_free(container);
    container = NULL;

    {
        QMutexLocker lock(&FFMpegVideo::mutex);
        avcodec_close(pCtx);
    }
    av_free(pCtx);
    pCtx = NULL;
    av_free(picture_yuv->data[0]);
    av_free(picture_yuv);
    picture_yuv = NULL;
    av_free(picture_rgb->data[0]);
    av_free(picture_rgb);
    picture_rgb = NULL;
}

#endif // USE_FFMPEG




点击打开链接

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值