ffmpeg实现前端截取视频帧功能和实现web在线转码播放

wasm + ffmpeg实现前端截取视频帧功能

有没有那么一种可能,在前端页面处理音视频?例如用户选择一个视频,然后支持他设置视频的任意一帧作为封面,就不用把整一个视频上传到后端处理了。经过笔者的一番摸索,基本实现了这个功能,一个完整的demo:ffmpeg wasm截取视频帧功能

支持mp4/mov/mkv/avi等文件。

基本的思想是这样的:

使用一个file input让用户选择一个视频文件,然后读取为ArrayBuffer,传给ffmpeg.wasm处理,处理完之后,输出rgb数据画到canvas上或者是转成base64当做img标签的src属性就形成图片了。(Canvas可以直接把video dom当作drawImage的对象进而得到视频帧,不过video能播放的格式比较少,本文重点讨论ffmpeg方案的实现,因为ffmpeg还可做其它的事情,这只是一个例子。)

这里有一个问题,为什么要借助ffmpeg呢,而不直接用JS写?因为多媒体处理的C库比较成熟,ffmpeg就是其中一个,还是开源的,而wasm刚好可以把它转化格式,在网页上使用,多媒体处理相关的JS库比较少,自己写一个多路解复用(demux)和解码视频的复杂度可想而知,JS直接编解码也会比较耗时。所以有现成的先用现成的。

第1步是编译(如果你对编译过程不感兴趣的话,可以直接跳到第2步)

1. 编译ffmpeg为wasm版本

我一开始以为难度会很大,后来发现并没有那么大,因为有一个videoconverter.js已经转过了(它是一个借助ffmpeg在网页实现音视频转码的),关键在于把一些没用的特性在configure的时候给disable掉,不然编译的时候会报语法错误。这里使用的是emsdk转的wasm,emsdk的安装方法在它的安装教程已经说得很明白,主要是使用脚本判定系统下载不同编译好的文件。下载好之后就会有几个可执行文件,包括emcc、emc++、emar等命令,emcc是C的编译器,emc++是C++的编译器,而emar是用于把不同的.o库文件打包成一个.a文件的。

先要在ffmpeg的官网下载源码。

(1)configure

解压进入目录,然后执行以下命令:

Shell

1

2

3

emconfigure ./configure --cc="emcc" --enable-cross-compile --target-os=none --arch=x86_32 --cpu=generic \

    --disable-ffplay --disable-ffprobe --disable-asm --disable-doc --disable-devices --disable-pthreads --disable-w32threads --disable-network \

    --disable-hwaccels --disable-parsers --disable-bsfs --disable-debug --disable-protocols --disable-indevs --disable-outdevs --enable-protocol=file

通常configure的作用是生成Makefile——configure阶段确认一些编译的环境和参数,然后生成编译命令放到Makefile里面。

而前面的emconfigure的主要作用是把编译器指定为emcc,但只是这样是不够的,因为ffmpeg里面有一些子模块,并不能彻底地把所有的编译器都指定为emcc,好在ffmpeg的configure可以通过–cc的参数指定自定义的编译器,在Mac上C编译器一般是使用/usr/bin/clang,这里指定为emcc。

后面的disable是把一些不支持wasm的特性给禁掉了,例如–disable-asm是把使用汇编代码的部分给禁掉了,因为那些汇编语法emcc不兼容,没有禁掉的话编译会报错语法错误。另外一个–disable-hwaccels是把硬解码禁用了,有些显卡支持直接解码,不需要应用程序解码(软解码),硬解码性能明显会比软解码的高,这个禁了之后,会导致后面使用的时候报了一个warning:

[swscaler @ 0x105c480] No accelerated colorspace conversion found from yuv420p to rgb24.

但是不影响使用。

(执行configure的过程会报一个segment fault,但后续的过程中发现没有影响。)

等待configure命令执行完了,就会生成Makefile和相关的一些配置文件。

(2)make

make是开始编译的阶段,执行以下命令进行编译:

Shell

1

emmake make

在Mac上执行,你会发现最后把多个.o文件组装成.a文件的时候会报错:

AR libavdevice/libavdevice.a
fatal error: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ar: fatal error in /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/ranlib

解决这个问题需要把打包的命令从ar改成emar,然后再把一个ranlib的过程去掉就行,修改ffbuild/config.mak文件:

Shell

1

2

3

4

5

6

7

# 修改ar为emar

- AR=ar

+ AR=emar

# 去掉ranlib

- RANLIB=ranlib

+ #RANLIB=ranlib

然后再重新make就可以了。

编译完成之后,会在ffmpeg目录生成一个总的ffmpeg文件,在ffmpeg的libavcodec等目录会生成libavcodec.a等文件,这些文件是后面我们要使用的bitcode文件,bitcode是一种已编译程序的中间代码。

(最后在执行strip -o ffmpeg ffmpeg_g命令会挂掉,但是不要紧,strip改成cp ffmpeg_g ffmpeg就好了)

2. 使用ffmpeg

ffmpeg主要是由几个lib目录组成的:

  •     libavcodec: 提供编解码功能
  •     libavformat:多路解复用(demux)和多路复用(mux)
  •     libswscale:图像伸缩和像素格式转化

以一个mp4文件为例,mp4是一种容器格式,首先使用libavformat的API把mp4进行多路解复用,得到音视频在这个文件存放的位置等信息,视频一般是使用h264等进行编码的,所以需要再使用libavcodec进行解码得到图像的yuv格式,最后再借助libswscale转成rgb格式。

这里有两个使用ffmpeg的方式,第一种是直接把第一步得到的ffmpeg文件编译成wasm:

Shell

1

2

3

# 需要拷贝一个.bc后缀,因为emcc是根据后缀区分文件格式的

cp ffmpeg_g ffmpeg.bc

emcc ffmpeg.bc -o ffmpeg.html

然后就会生成一个ffmpeg.js和ffpmeg.wasm,ffmpeg.js是用来加载和编译wasm文件以及提供一个全局的Module对象用来操控wasm里面ffmpeg API的功能的。有了这个之后,在JS里面通过Module调用ffmpeg的API。

但是我感觉这个方式比较麻烦,JS的数据类型和C的数据类型差异比较多,在JS里面频繁地调C的API,需要让数据传来传去比较麻烦,因为要实现一个截取功能要调很多ffmpeg的API。

所以我用的是第二种方式,先写C代码,在C里面把功能实现了,最后再暴露一个接口给JS使用,这样JS和WASM只需要通过一个接口API进行通信就好了,不用像第一种方式一样频繁地调用。

所以问题就转化成两步:

第一步是使用C语言写一个ffmpeg保存视频帧图像的功能

第二步是编译成wasm和js进行数据的交互

第一步的实现主要参考了一个ffmpeg的教程:ffmpeg tutorial。里面的代码都是现成的直接拷过来就好,有一些小问题是他用的ffmpeg版本稍老,部分API的参数需要修改一下。代码已上传到github,可见:cfile/simple.c

使用方法已在readme里面进行介绍,通过以下命令编译成一个可执行文件simple:

Shell

1

gcc simple.c -lavutil -lavformat -lavcodec `pkg-config --libs --cflags libavutil` `pkg-config --libs --cflags libavformat` `pkg-config --libs --cflags libavcodec` `pkg-config --libs --cflags libswscale` -o simple

然后使用的时候传一个视频文件的位置就可以了:

Shell

1

./simple mountain.mp4

就会在当前目录生成一张pcm格式的图片。

这个simple.c是调用的ffmpeg自动读取硬盘文件的api,需要改成从内存读取文件内容,即我们自己读到内存的buffer然后传给ffmpeg,后面才能把数据传输改成从JS的buffer获取,这个的实现可见:simple-from-memory.c. 具体的C代码这里就不分析了,就是调调API,相对来说还是比较简单,就是要知道怎么用,ffmpeg网上的开发文档相对较少。

这样第一步就算完成了,接着第二步,把数据的输入改成从JS获取,输出改成返回给JS.

3. js和wasm的交互

wasm版的具体实现是在web.c(还有一个proccess.c是把simple.c的一些功能拆了出去),在web.c里面有一个暴露给JS调用的函数,姑且起名叫setFile,这个setFile就是给JS调的:

C

1

2

3

4

5

EMSCRIPTEN_KEEPALIVE // 这个宏表示这个函数要作为导出的函数

ImageData *setFile(uint8_t *buff, const int buffLength, int timestamp) {

    // process ...

    return result;

}

需要传递三个参数:

  • buff:原始的视频数据(通过JS的ArrayBuffer传进来)
  • buffLength:视频buff的总大小(单位字节)
  • timestamp:是希望截取第几秒的视频帧

最后处理完了返回一个ImageData的数据结构:

C

1

2

3

4

5

typedef struct {

    uint32_t width;

    uint32_t height;

    uint8_t *data;

} ImageData;

里面有三个字段:图片的宽高和rgb数据。

写好这些C文件后进行编译:

Shell

1

2

emcc web.c process.c ../lib/libavformat.bc ../lib/libavcodec.bc ../lib/libswscale.bc ../lib/libswresample.bc ../lib/libavutil.bc \

    -Os -s WASM=1 -o index.html -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"]' -s ALLOW_MEMORY_GROWTH=1 -s TOTAL_MEMORY=16777216

使用第1步编译生成的那些libavcode.bc等文件,这些文件有依赖顺序,前后不能颠倒,被依赖的要放在后面。这里面有些参数说明一下:

-o index.html表示导出hmtl文件,同时会导出index.jsindex.wasm,主要使用这两个,生成的index.html是没用的;

-s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall", "cwrap"] 表示要导出ccall和cwrap这两个函数,这两个函数的功能是为了调用上面C里面写的setFile函数;

-s TOTAL_MEMORY=16777216 表示wasm总内存大小为约16MB,这个也是默认值,这个需要是64的倍数;

-s ALLOW_MEMORY_GROWTH=1 当内存超出总大小时自动扩容。

编译好之后写一个main.html,加入input[type=file]等控件,并引入上面生成的index.js,它会去加载index.wasm,并提供一个全局的Module对象操控wasm的API,包括上面在编译的时候指定导出的函数,如下代码所示:

XHTML

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

<!DOCType html>

<html>

<head>

    <meta charset="utf-8">

    <title>ffmpeg wasm截取视频帧功能</title>

</head>

<body>

<form>

    <p>请选择一个视频(本地操作不会上传)</p>

    <input type="file" required name="file">

    <label>时间(秒)</label><input type="number" step="1" value="0" required name="time">

    <input type="submit" value="获取图像" style="font-size:16px;">

</form>

<!--这个canvas用来画导出的图像-->

<canvas width="600" height="400" id="canvas"></canvas>

<!--引入index.js-->

<script src="index.js"></script>

<script>

<script>

!function() {

   let setFile = null;

   // WASM下载并解析完毕

   Module.onRuntimeInitialized = function () {

        console.log('WASM initialized done!');

        // 导出的核心处理函数

        setFile = Module.cwrap('setFile', 'number',

                      ['number', 'number', 'number']);

   };

}();

</script>

需要在wasm下载并解析完成之后才能开始操作,它提供了一个onRuntimeInitialized的回调。

为了能够使用C文件里面导出的函数,可以使用Module.cwrap,第一个参数是函数名,第二个参数是返回类型,由于返回的是一个指针地址,这里是一个32位的数字,所以用js的number类型,第三个参数是传参类型。

接着读取input的文件内容到放到一个buffer里面:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

let form = document.querySelector('form');

// 监听onchange事件

form.file.onchange = function () {

    if (!setFile) {

        console.warn('WASM未加载解析完毕,请稍候');

        return;

    }

    let fileReader = new FileReader();

    fileReader.onload = function () {

        // 得到文件的原始二进制数据ArrayBuffer

        // 并放在buffer的Unit8Array里面

        let buffer = new Uint8Array(this.result);

        // ...

    };

    // 读取文件

    fileReader.readAsArrayBuffer(form.file.files[0]);

};

读取得到的buffer放在了一个Uint8Array,它是一个数组,数组里面每个元素都是unit8类型的即无符号8位整型,就是一个字节的0101的数字大小。

接下来的关键问题是:怎么把这个buffer传给wasm的setFile函数?这个需要理解wasm的内存堆模型。

4. wasm的内存堆模型

上面在编译的时候指定的wasm使用的总内存大小,内存里面的内容可以通过Module.buffer和Module.HEAP8查看:

这个东西就是JS和WASM数据交互的关键,在JS里面把数据放到这个HEAP8的数组里面,然后告诉WASM数据的指针地址在哪里和占用的内存大小,即在这个HEAP8数组的index和占用长度,反过来WASM想要返回数据给JS也是被放到这个HEA8里面,然后返回指针地址和和长度。

但是我们不能随便指定一个位置,需要用它提供的API进行分配和扩容。在JS里面通过Module._molloc或者Module.dynamicMalloc申请内存,如下代码所示:

JavaScript

1

2

3

4

5

6

7

8

9

// 得到文件的原始二进制数据,放在buffer里面

let buffer = new Uint8Array(this.result);

// 在HEAP里面申请一块指定大小的内存空间

// 返回起始指针地址

let offset = Module._malloc(buffer.length);

// 填充数据

Module.HEAP8.set(buffer, offset);

// 最后调WASM的函数

let ptr = setFile(offset, buffer.length, +form.time.value * 1000);

调用malloc,传需要的内存空间大小,然后会返回分配好的内存起始地址offset,这个offset其实就是HEAP8数组里的index,然后调用Uint8Array的set方法填充数据。接着把这个offset的指针地址传给setFile,并告知内存大小。这样就实现了JS向WASM传数据。

调用setFile之后返回值是一个指针地址,指向一个struct的数据结构:

C

1

2

3

4

5

typedef struct {

    uint32_t width;

    uint32_t height;

    uint8_t *data;

} ImageData;

它的前4个字节,用来表示宽度,紧接着的4个字节是高度,后面的是图片的rgb数据的指针,指针的大小也是4个字节,这个省略了数据长度,因为可以通过width * height * 3得到。

所以[ptr, ptr + 4)存的内容是宽度,[ptr + 4, ptr + 8)存的内容是长度,[ptr + 8, ptr + 12)存的内容是指向图像数据的指针,如下代码所示:

JavaScript

1

2

3

4

5

6

let ptr = setFile(offset, buffer.length, +form.time.value * 1000);

let width = Module.HEAPU32[ptr / 4]

    height = Module.HEAPU32[ptr / 4 + 1],

    imgBufferPtr = Module.HEAPU32[ptr / 4 + 2],

    imageBuffer = Module.HEAPU8.subarray(imgBufferPtr,

                      imgBufferPtr + width * height * 3);

HEAPU32和上面的HEAP8是类似的,只不过它是每个32位就读一个数,由于我们上面都是32位的数字,所以用这个刚刚好,它是4个字节一个单位,而ptr是一个字节一个单位,所以ptr / 4就得到index。这里不用担心不能够被4整除,因为它是64位对齐的。

这样我们就拿到图片的rgb数据内容了,然后用canvas画一下。

5. Canvas画图像

利用Canvas的ImageData类,如下代码所示:

JavaScript

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

function drawImage(width, height, buffer) {

    let imageData = ctx.createImageData(width, height);

    let k = 0;

    // 把buffer内存放到ImageData

    for (let i = 0; i < buffer.length; i++) {

        // 注意buffer数据是rgb的,而ImageData是rgba的

        if (i && i % 3 === 0) {

            imageData.data[k++] = 255;

        }

        imageData.data[k++] = buffer[i];

    }

    imageData.data[k] = 255;

    memCanvas.width = width;

    memCanvas.height = height;

    canvas.height = canvas.width * height / width;

    memContext.putImageData(imageData, 0, 0, 0, 0, width, height);

    ctx.drawImage(memCanvas, 0, 0, width, height, 0, 0, canvas.width, canvas.height);

}

drawImage(width, height, imageBuffer);

这样基本就完工了,但是还有一个很重要的事情要做,就是把申请的内存给释放,不然反复操作几次之后,网页的内存就飙到一两个G,然后就抛内存不够用异常了,所以在drawImage后之后把申请的内存释放了:

JavaScript

1

2

3

4

5

drawImage(width, height, imageBuffer);

// 释放内存

Module._free(offset);

Module._free(ptr);

Module._free(imgBufferPtr);

在C里面写的代码也要释放掉中间过程申请的内存,不然这个内存泄露还是挺厉害的。如果正确free之后,每次执行malloc的地址都是16358200,没有free的话,每次都会重新扩容,返回递增的offset地址。

但是这个东西整体消耗的内存还是比较大。

6. 存在的问题

初始化ffmpeg之后,网页使用的内存就飙到500MB,如果选了一个300MB的文件处理,内存就会飙到1.3GB,因为在调setFile的时候需要malloc一个300MB大小的内存,然后在C代码的setFile执行过程中又会malloc一个300MB大小的context变量,因为要处理mov/m4v格式的话为了获取moov信息需要这么大的,暂时没优化,这几个加起来就超过1GB了,并且WebAssembly.Memory只能grow,不能shrink,即只能往大扩,不能往小缩,扩充后的内存就一直在那里了。而对于普通的mp4文件,context变量只需要1MB,这个可以把内存控制在1GB以内。

第二个问题是生成的wasm的文件比较大,原始有12.6MB,gzip之后还有5MB,如下图所示:

因为ffmpeg本身比较大,如果能够深入研究源码,然后把一些没用的功能disable掉或者不要include进来应该就可以给它瘦身,或者是只提取有用的代码,这个难度可能略高。

第三个问题是代码的稳健性,除了想办法把内存降下来,还需要考虑一些内存访问越界的问题,因为有时候跑着跑着就抛了这个异常:

Uncaught RuntimeError: memory access out of bounds

虽然存在一些问题,但是起码已经跑起来,可能暂时还不具备部署生产环境的价值,后面可以慢慢优化。

除了本文这个例子外,还可以利用ffmpeg实现其它一些功能,让网页也能够直接处理多媒体。基本上只要ffmpeg能做的,在网页也是能跑,并且wasm的性能要比直接跑JS的高。

ffmpeg实现web在线转码播放

通常我们使用MP4格式,但是MP4又分为“1.MPEG4(DivX)”、“2.MPEG4(Xvid)”、“3.AVC(H264)”三种类型。其中只有H264类型的视频才能进行html播放。


标签所支持的视频格式和编码:
MP4 = MPEG 4文件使用 H264 视频编解码器和AAC音频编解码器
WebM = WebM 文件使用 VP8 视频编解码器和 Vorbis 音频编解码器
Ogg = Ogg 文件使用 Theora 视频编解码器和 Vorbis音频编解码器

格式IEFirefoxOperaChromeSafari
OggNo3.5+10.5+5.0+No
MPEG49.0+NoNo5.0+3.0+
WebMNo4.0+10.6+6.0+No

注释:Internet Explorer 8 以及更早的版本不支持 video 标签。

使用FFMPEG转码

预览地址: 642134542.github.io/vue-ffmpeg/

1、原理:
ffmpeg.wasm 是 FFmpeg 的纯 WebAssembly / JavaScript 端口。它支持在浏览器内录制、转换和流式传输视频和音频。
Webassembly 的出现为前端转码提供可能


2、按照官网配置


2.1 npm安装


npm install @ffmpeg/ffmpeg @ffmpeg/core


2.2 html

 <div>     
<h3>Upload a video to transcode to mp4 (x264) and play!</h3>     
<video id="output-video" controls ></video><br/>     
<input type="file" id="uploader">     
<p id="message"></p>    
</div> 


2.3 js

const { createFFmpeg, fetchFile } = FFmpeg;  
const ffmpeg = createFFmpeg({     
 corePath: 'https://unpkg.com/@ffmpeg/core@0.8.5/dist/ffmpeg-core.js',     
 log: true,  
 });  const transcode = async ({ target: { files } }) => {    
const message = document.getElementById('message'); 
const { name } = files[0]; 
message.innerHTML = 'Loading ffmpeg-core.js';   
await ffmpeg.load();   
ffmpeg.FS('writeFile', 'name', await fetchFile(files[0]));
message.innerHTML = 'Start transcoding'; 
await ffmpeg.run('-i', 'name', 'output.mp4');
 message.innerHTML = 'Complete transcoding';    
const data = ffmpeg.FS('readFile', 'output.mp4');    
const video = document.getElementById('output-video');
 video.src = URL.createObjectURL(new Blob([data.buffer], { type: 'video/mp4' }));   
  }     
const elm = document.getElementById('uploader');    
 elm.addEventListener('change', transcode); 

【学习地址】: FFmpeg/WebRTC/RTMP/NDK/Android音视频流媒体高级开发
【文章福利】:免费领取更多音视频学习资料包、大厂面试题、技术视频和学习路线图,资料包括(C/C++,Linux,FFmpeg webRTC rtmp hls rtsp ffplay srs 等等)有需要的可以点击 1079654574 加群领取哦~


3、结果


页面可以正常加载和上传,但是在转码过程中报错了
ReferenceError: SharedArrayBuffer is not defined,
所以需要解决SharedArrayBuffer报错


兼容性查看caniuse.com/?search=Sha…


可以看到SharedArrayBuffer是支持谷歌浏览器79版本以上
那么,为什么这里会出现SharedArrayBuffer is not defined的报错信息呢
经过一番百度和查看issue, 是因为谷歌浏览器的安全策略机制改变了
2017.7月(Chrome 60)引入 SharedArrayBuffer。
2021.7月(Chrome 92)限制 SharedArrayBuffer只能在 cross-origin isolated 页面使用。
Android Chrome 88 也进行了同样的限制。
从上述结论中,可以知道在60-91的版本的浏览器是可以正常打开的
那么92版本的呢
在vue-cli开发环境中我们可以再vue.config.js中配置
devServer: {

headers: {

"Cross-Origin-Opener-Policy": "same-origin",

"Cross-Origin-Embedder-Policy": "require-corp",

},

}
4、效果
在控制台中可以看到读取文件和转码进程,
而且页面可以正常的显示视频并播放


5、优化:
5.1、corePath
createFFmpeg中的corePath的地址是cdn在线的,这里我们需要换成自己的本地资源,
但是使用import一直报错


估计是相关依赖并没有找到,npm install还是不行
只好把包里的ffmpeg-core.jsffmpeg-core.wasm和ffmpeg-core.worker.js放在public中


5.2、加载
读取文件和转码的时间比较长,所以我们需要增加加载层和进度条
ffmpeg.setProgress(({ ratio }) => { console.log(ratio); this.percentage = Math.floor(ratio * 100) /* * ratio is a float number between 0 to 1. */ }); 复制代码
6、部署:
使用ip或者域名访问,依旧会报错SharedArrayBuffer is not defined,所以我们需要代理中配置请求头
add_header Cross-Origin-Opener-Policy same-origin; add_header Cross-Origin-Embedder-Policy require-corp; 复制代码
但是还是存在报错信息


解决办法
1、使用https代替http
2、使用chrome 60-91版本访问
7、可能存在报错信息
7.1、caught (in promise) Error: Oops, something went wrong in FS operation


解决办法: 不要使用中文名称,将name可以进行转义


7.2、Error: ffmpeg.FS('readFile', 'output.mp4') error. Check if the path exists


解决办法:同问题1
7.3、github page无法设置header请求头,预览地址是如何解决SharedArrayBuffer报错的
在Stack Overflow这个问题中 有个回答提供了解决方案 github.com/gzuidhof/co… 开发者只需要将coi-serviceworker.js在index.html引入即可
<script src="coi-serviceworker.js"></script> 复制代码
参考链接:
1、juejin.cn/post/701696…



作者:ho
链接:https://juejin.cn/post/7078898276203757581
来源:稀土掘金

转载于:wasm + ffmpeg实现前端截取视频帧功能 – 会编程的银猪

ffmpeg实现web在线转码播放 - 知乎

git:GitHub - ffmpegwasm/ffmpeg.wasm-core at v0.10.0

       https://github.com/bgrins/videoconverter.js

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值