FileBufferReader.js 实战指南:基于 WebRTC DataChannel 的分块文件传输方案
【免费下载链接】WebRTC-Experiment
WebRTC, WebRTC and WebRTC. Everything here is all about WebRTC!!
导读
FileBufferReader 是 WebRTC-Experiment 系列中的轻量级 JavaScript 库,核心职责是"把文件读成按指定 chunkSize 切分的 ArrayBuffer 列表",并配合你的 WebRTC DataChannel 或任意信令通道完成点对点文件传输。它的设计目标是模拟 Skype 的"受控缓冲传输"(controlled-buffers transmission)风格:发送端不一次性把整个文件塞进数据通道,而是按接收端的"就绪信号"逐块发送,从而避免慢网络下的丢包与拥塞。读完本文,你将掌握 FileBufferReader 的完整 API、端到端收发流程、多用户共享、进度条集成以及底层分块/序列化原理,可以直接在 RTCMultiConnection 或自建 PeerConnection 项目中落地文件共享功能。
项目定位与核心能力
FileBufferReader 是独立于 RTCMultiConnection 之外的一个 npm 包(包名 fbr),但其作者与 WebRTC-Experiment 仓库同一人,且仓库内提供了完整的本地运行 demo(FileBufferReader/index.html)。
它的能力可以概括为两点:
- 按指定 chunkSize 获取 ArrayBuffer 分块列表:读取本地文件,将其切分为多个 ArrayBuffer,并连同文件元数据一起存入
chunks对象。 - 支持逐块(step-by-step)或循环即时(for-loop)发送:既可以在回调中一块一块地取(推荐),也可以自行遍历
chunks一次性取出所有分块。
此外,由于每个 chunk 都带有 uuid、currentPosition、maxChunks 等元数据,你可以轻松实现**分块重传(retransmission)**机制——前提是把数据通道的 binaryType 设置为 arraybuffer:
WebRTC_Data_Channel.binaryType = 'arraybuffer';
设计前提与限制(务必先读)
README 明确列出了四个关键点,理解它们有助于避免踩坑:
- FileBufferReader 本身不做任何"传输"工作,它只负责读取文件并给出分块。发送什么、走什么通道(DataChannel、Socket.io、WebSocket……)完全由你决定。
- 你需要手动用自己偏好的通道共享分块:库不内置 PeerConnection 逻辑。
- chunks 存放在内存中,有存储上限:因此不适合用 FileBufferReader 读取/传输 1GB 及以上大小的文件。这与 FileBufferReaderHelper.js 中全量切片入内存的实现一致。
- 设计目标:支持受控缓冲传输,模仿 Skype 的文件分享节奏——发送端等待接收端"请求下一块"。
快速开始:安装与运行
npm / bower 安装
npm install fbr --production
# 或者使用 bower
bower install fbr
在页面中引入
本地安装后可直接引用仓库根目录构建产物:
<script src="./node_modules/fbr/FileBufferReader.js"></script>
<script src="./bower_components/fbr/FileBufferReader.js"></script>
在仓库中,可直接引用根目录的 FileBufferReader.js(完整版)或 FileBufferReader.min.js(压缩版)。
运行本地 demo
仓库根目录提供了 server.js,启动后即可体验完整的文件共享 demo:
node server.js
然后打开 http://localhost:9001/ 或 http://127.0.0.1:9001/。demo 页面(FileBufferReader/index.html)内置了 chunkSize 选择器(60000 / 50000 / 40000 / 30000 / 20000 / 16000 / 8000 字节)、拖拽上传、进度条、暂停/恢复/取消传输等完整交互,是学习真实用法的极佳范例。
独立的 Socket.io 客户端(fbr-client)
如果你想跳过 WebRTC 直接体验分块传输流程,可以用独立的 fbr-client:
npm install fbr-client
然后启动其内置服务器(仓库内对应目录为 fbr-client):
cd ./node_modules/fbr-client
node server.js port=9001
同样打开 http://localhost:9001/。
提示:修改
dev目录下的开发文件后,可用grunt重新编译出根目录的FileBufferReader.js。编译流水线见 Gruntfile.js:按顺序 concatdev/head.js、FileBufferReader.js、FileBufferReaderHelper.js、FileSelector.js、FileBufferReceiver.js、FileConverter.js、common.js、binarize.js、tail.js,再经 jsbeautifier 与 uglify 产出 min 版本。
API 总览
FileBufferReader 实例对外暴露以下核心成员:
| API / 属性 | 作用 |
|---|---|
chunks 对象 | 存放所有文件的切块。每个文件以 file-uuid 为键,值为分块索引结构(含元数据块与真实数据块)。即使是从远端收到并经过 addChunk 存入的分块,也统一放在同一个 chunks 对象中。取值方式:var fileChunks = fileBufferReader.chunks['file-uuid'] |
readAsArrayBuffer(file, callback, extra) | 读取整个文件,将切块化的缓冲存入 chunks 对象 |
getNextChunk(fileUUID, callback, userid) | 读取 last-position,返回下一个可用的 ArrayBuffer 分块 |
onBegin / onEnd / onProgress 事件 | 三个可赋值的事件回调,专门用于驱动文件进度条 |
addChunk(chunk, callback) | 把接收到的分块暂存到数组,直到整个文件接收完毕 |
convertToObject(buffer, callback) | 将 ArrayBuffer 转换回 JavaScript 对象(用于 onmessage 里判断消息类型) |
convertToArrayBuffer(object, callback) | 反向操作,将任意 JS 对象/数据类型转换为 ArrayBuffer |
源码实现位于 FileBufferReader/dev/FileBufferReader.js:构造时会创建 fbr.chunks = {} 与 fbr.users = {},并把 convertToObject / convertToArrayBuffer 直接别名到 FileConverter 的对应方法。
实战一:发送端读取文件并逐块发送
1. 引入库
在仓库环境中最简单的方式是直接引用根目录的构建产物(见上文"在页面中引入")。
2. 选择文件(可选步骤)
README 特别强调:强烈推荐使用 input[type=file].onchange 自行选择文件,而不是依赖 FileSelector,因为 FileSelector 无法妥善处理浏览器不触发 onchange 事件等失败场景。若要用 FileSelector,写法如下:
var fileSelector = new FileSelector();
// 可指定 '*.png'、'*.jpeg'、'*.mp4' 等,默认 '*.*'
fileSelector.accept = '*.*';
var btnSelectFile = document.getElementById('select-file');
btnSelectFile.onclick = function() {
fileSelector.selectSingleFile(function(file) {
// file == input[type=file]
});
};
3. 读取 Buffers
var fileBufferReader = new FileBufferReader();
fileBufferReader.readAsArrayBuffer(file, function(fileUUID) {
// var file = fileBufferReader.chunks[fileUUID];
// var listOfChunks = file.listOfChunks;
// 取第一块,然后用 WebRTC 数据通道发送
// 千万不要在循环里发送所有分块;慢网络下会出问题
// 正确做法是等远端通知"可以发下一块"再继续
fileBufferReader.getNextChunk(fileUUID, function(nextChunk, isLastChunk) {
if (isLastChunk) {
alert('File Successfully sent.');
}
// 使用 WebRTC 数据通道发送
datachannel.send(nextChunk);
});
});
readAsArrayBuffer 还接受第 3 个参数 extra,用于传入 chunkSize 和自定义数据:
var extra = {
chunkSize: 15 * 1000, // 默认 15KB;Firefox 的接收上限约 16k
userid: 'sender-userid' // 最常用的对象,用于区分发送者
};
fileBufferReader.readAsArrayBuffer(file, callback, extra);
chunkSize 的实现细节:在 FileBufferReaderHelper.js 的 fileReaderWrapper 中,chunkSize 默认取 15 * 1000 字节;如果 options.extra.chunkSize 存在则优先使用 extra 中的值。切片过程会把整个文件按 maxChunks = Math.ceil(file.size / chunkSize) 切成若干块,并用 file.slice(...) + FileReader.readAsArrayBuffer 分批读取,每批(slice)内再按 chunkSize 二次切分。
实战二:接收端处理远端分块
接收端(或同一端的 onmessage)需要区分三种情况:收到的是原始 ArrayBuffer(需转回对象)、收到"请求下一块"信号、收到真实数据块:
datachannel.onmessage = function(event) {
var chunk = event.data;
if (chunk instanceof ArrayBuffer || chunk instanceof DataView) {
// WebRTC 数据通道传来的都是 ArrayBuffer
// 需要先转回 JavaScript 对象
fileBufferReader.convertToObject(chunk, function(object) {
datachannel.onmessage({
data: object
});
});
return;
}
// 如果发送时传了 extra 数据,可在这里访问:
// chunk.extra.senderUserName 或其他自定义字段
// 远端请求下一块
if (chunk.readyForNextChunk) {
fileBufferReader.getNextChunk(chunk.uuid, function(nextChunk, isLastChunk) {
if (isLastChunk) {
alert('File Successfully sent.');
}
// 使用 WebRTC 数据通道发送
datachannel.send(nextChunk);
});
return;
}
// 收到数据块:addChunk 会暂存分块,
// 并在必要时通过回调返回"请求下一块"的信令对象
fileBufferReader.addChunk(chunk, function(promptNextChunk) {
// 请求下一块
datachannel.send(promptNextChunk);
});
};
关键协议对象:promptNextChunk 是一个包含 readyForNextChunk: true、currentPosition、uuid 的对象(见 FileBufferReader.js 中 addChunk 的实现),接收方每收一块就回发一次,发送方据此触发 getNextChunk。这就是"受控缓冲传输"的核心握手。
接收端的重组逻辑:在 FileBufferReceiver.js 中,收到 chunk.start 时初始化该文件的分块容器并触发 onBegin;收到带 chunk.buffer 的非末尾块时按 currentPosition 存位;收到 chunk.end 时把所有块合成 Blob(new Blob(chunksArray, { type: chunk.type })),生成 blob.url,触发 onEnd 并立即清理内存。此外它还内置了一个 chunksWaiters 重试循环:若某一块缺失(chunk.currentPosition != chunk.maxChunks && !chunks[uuid][currentPosition]),会每 5 秒重试一次回调,用于应对乱序或丢块场景。
实战三:进度条集成
方案 A:使用 FileProgressBarHandler.js
先引入进度条脚本(仓库内可参考 demo 中自行实现的进度 UI,见 FileBufferReader/index.html),准备一个容器 div:
<div id="files-container"></div>
然后接线:
// 可选:指定进度条与文件预览的容器 <DIV>
fileBufferReader.filesContainer = document.getElementById('files-container');
// 这行会把 onFileStart / onFileProgress / onFileEnd 三个回调设置到 FileBufferReader 上
FileProgressBarHandler.handle(fileBufferReader);
fileBufferReader.onBegin = fileBufferReader.onFileStart;
fileBufferReader.onProgress = fileBufferReader.onFileProgress;
fileBufferReader.onEnd = fileBufferReader.onFileEnd;
等价写法(用 options 对象):
var options = {};
// 可选:指定进度条与文件预览的容器 <DIV>
options.filesContainer = document.getElementById('files-container');
// 设置 onFileStart / onFileProgress / onFileEnd 回调
FileProgressBarHandler.handle(options);
fileBufferReader.onBegin = options.onFileStart;
fileBufferReader.onProgress = options.onFileProgress;
fileBufferReader.onEnd = options.onFileEnd;
方案 B:不用 FileProgressBarHandler,手写进度 UI
var progressHelper = {};
var outputPanel = document.body;
var FileHelper = {
onBegin: function(file) {
// 若传了 extra 数据可在此访问:file.extra.senderUserName 等
var li = document.createElement('li');
li.title = file.name;
li.innerHTML = '<label>0%</label> <progress></progress>';
outputPanel.insertBefore(li, outputPanel.firstChild);
progressHelper[file.uuid] = {
li: li,
progress: li.querySelector('progress'),
label: li.querySelector('label')
};
progressHelper[file.uuid].progress.max = file.maxChunks;
},
onEnd: function(file) {
// 若传了 extra 数据可在此访问:file.extra.senderUserName 等
progressHelper[file.uuid].li.innerHTML = '<a href="' + file.url + '" target="_blank" download="' + file.name + '">' + file.name + '</a>';
},
onProgress: function(chunk) {
// 若传了 extra 数据可在此访问:chunk.extra.senderUserName 等
var helper = progressHelper[chunk.uuid];
helper.progress.value = chunk.currentPosition || chunk.maxChunks || helper.progress.max;
updateLabel(helper.progress, helper.label);
}
};
function updateLabel(progress, label) {
if (progress.position == -1) return;
var position = +progress.position.toFixed(2).split('.')[1] || 100;
label.innerHTML = position + '%';
}
fileBufferReader.onBegin = FileHelper.onBegin;
fileBufferReader.onProgress = FileHelper.onProgress;
fileBufferReader.onEnd = FileHelper.onEnd;
回调触发时机(源码佐证):在 FileBufferReader.js 的 getNextChunk 中,发送端每次取块都会依次触发 onBegin(当块带 start 标记)、onEnd(当块带 end 标记)和 onProgress;在接收端,FileBufferReceiver.receive 收到 chunk.start 触发 onBegin、收到 chunk.end 触发 onEnd、收到带 buffer 的块触发 onProgress。两端的事件语义一致,因此同一套 UI 代码可以复用。
多用户共享
每个远端用户可以拥有独立的传输游标。做法是把 userid 作为第 3 个参数传给 getNextChunk:
fbr.readAsArrayBuffer(file, function(fileUUID) {
['first-user', 'second-user', 'third-user'].forEach(function(userid) {
fbr.getNextChunk(fileUUID, function(nextChunk, isLastChunk) {
specific_datachannel.send(nextChunk);
}, userid);
});
});
datachannel.onmessage = function(event) {
fbr.getNextChunk(message.uuid, function(nextChunk, isLastChunk) {
specific_datachannel.send(nextChunk);
}, specific_userid);
};
关键:把具体的
userid作为第 3 个参数传给getNextChunk方法。
源码原理:getNextChunk 内部维护 fbr.users[userid] 这一独立的游标记录(currentPosition 独立递增),并为取出的块附加 remoteUserId = userid(见 FileBufferReader.js 第 30-102 行)。这样每个远端都有自己的进度位置,互不干扰。
要区分"某个文件正被共享给多个用户"以便为每个用户绘制独立进度条,可检查 remoteUserId:
FileHelper.onBegin = function(file) {
if (file.remoteUserId) {
// 该文件正在被共享给多个用户
}
};
FileHelper.onEnd = function(file) {
if (file.remoteUserId) {
// 该文件正在被共享给多个用户
}
};
FileHelper.onProgress = function(chunk) {
if (chunk.remoteUserId) {
// 该文件正在被共享给多个用户
}
};
高级用法:直接访问 chunks 并自行处理
如果不想走 getNextChunk 的受控流程,可以绕开它,直接操作 fbr.chunks:
var fbr = new FileBufferReader();
fbr.readAsArrayBuffer(file, function(fileUUID) {
// 不调用 getNextChunk
// 而是自己处理/使用/访问所有分块
var thisFileChunks = fbr.chunks[fileUUID];
var numberOfFileChunks = thisFileChunks[0].maxChunks;
var arrayOfRealBuffers = [];
for (var i = 1; i < numberOfFileChunks; i++) {
var fileChunk = thisFileChunks[i];
var realArrayBufferObject = fileChunk.buffer;
arrayOfRealBuffers.push(realArrayBufferObject);
}
var fileBlob = new Blob(realArrayBufferObject, {
type: thisFileChunks[0].type
});
var blobURL = URL.createObjectURL(fileBlob);
document.write('<iframe style="width: 100%; height: 100%; border:0; " src="' + blobURL + '"></iframe>');
});
chunks 对象的结构
fileBufferReader.chunks 的结构如下(README 原文示例,文件 WebRTC.png,20 块):
fileBufferReader.chunks =
{
// "4152661527041346" 是 file-uuid
"4152661527041346": {
// 索引 "0" 用于触发 "onStart" 事件
"0": {
"currentPosition": 0,
"uuid": "4152661527041346",
"maxChunks": 20,
"size": 298540,
"name": "WebRTC.png",
"type": "image/png",
"lastModifiedDate": "Wed Oct 14 2015 15:51:26 GMT+0500 (PKT)",
"start": true,
"userid": 0,
"extra": {
"userid": 0
}
},
// 索引 "1" 到 "maxChunks" 才是真正的数据块
"1": {
"uuid": "4152661527041346",
// 这是真正的 ArrayBuffer 对象
"buffer": {},
"currentPosition": 1,
"maxChunks": 20,
"size": 298540,
"name": "WebRTC.png",
"lastModifiedDate": "Wed Oct 14 2015 15:51:26 GMT+0500 (PKT)",
"type": "image/png",
"userid": 0,
"extra": {
"userid": 0
}
},
....
....
// 最后一个数据块
// 此时 "currentPosition === maxChunks"
"20": {
"uuid": "4152661527041346",
"buffer": {},
"currentPosition": 20,
"maxChunks": 20,
"size": 298540,
"name": "WebRTC.png",
"lastModifiedDate": "Wed Oct 14 2015 15:51:26 GMT+0500 (PKT)",
"type": "image/png",
"userid": 0,
"extra": {
"userid": 0
}
},
// 该块用于触发 "onEnd" 事件
"21": {
"currentPosition": 21,
"uuid": "4152661527041346",
"maxChunks": 20,
"size": 298540,
"name": "WebRTC.png",
"lastModifiedDate": "Wed Oct 14 2015 15:51:26 GMT+0500 (PKT)",
"url": "blob:http%3A//domain/fe5a20d0-cdb4-4f4e-9de7-1a14340fc402",
"type": "image/png",
"end": true,
"userid": 0,
"extra": {
"userid": 0
}
},
// 可选字段,用于记录当前正在共享的是哪一块;应跳过
"currentPosition": 0
}
}
结构规律(README 明确说明):真实数据块从索引 1 开始,到 length - 1 之前结束。例如:
var fileChunks = fbr.chunks['file-uuid'];
var allFileIndices = Object.keys(fileChunks);
var allFileBuffers = [];
for (var chunkIndex = 1; chunkIndex < allFileIndices.length; chunkIndex++) {
var chunk = fileChunks[chunkIndex];
allFileBuffers.push(chunk.buffer);
}
大文件提前回调的细节:在 FileBufferReaderHelper.js 的 processChunk 中,除了末尾块会触发早回调外,当 maxChunks > 200 且当前块序号到达 200 时也会提前触发一次回调(earlyCallback),从而让发送端尽早开始传输前 200 块,不必等整个文件读完——这对大文件的首包延迟有明显改善。
辅助对象:FileSelector
FileSelector 提供选择单个文件、多个文件或整个目录的方法(源码见 FileSelector.js,内部通过动态创建 input[type=file] 并派发 click 事件实现)。
选择单个文件:
var selector = new FileSelector();
selector.accept = '*.png';
selector.selectSingleFile(function(file) {
alert(file.name);
}, function() {
alert('User did not select any file.');
});
选择多个文件:
var selector = new FileSelector();
selector.accept = '*.png';
selector.selectMultipleFiles(function(files) {
files.forEach(function(file) {
alert(file.name);
});
}, function() {
alert('User did not select any file.');
});
选择整个目录:
var selector = new FileSelector();
selector.accept = '*.png';
selector.selectDirectory(function(files) {
files.forEach(function(file) {
alert(file.webkitRelativePath);
});
}, function() {
alert('User did not select any file.');
});
失败回调的实现细节:FileSelector 利用"文件对话框关闭后页面重新获得焦点"这一时机,在 document.body.onfocus 里延迟 500ms 检查 input.value 是否为空,从而判断用户是否取消了选择并触发失败回调(FileSelector.js 第 46-60 行)。这正是 README 建议优先使用原生 input[type=file].onchange 的原因——onchange 不触发时该兜底方案也不一定可靠。
辅助对象:FileConverter
全局对象 FileConverter 暴露两个方法(实现见 FileConverter.js,底层调用 binarize.pack / binarize.unpack):
ConvertToArrayBuffer(object, callback)ConvertToObject(buffer, callback)
用法:
var yourObject = {
x: 0,
y: 1,
str: 'string',
bool: true
};
FileConverter.ConvertToArrayBuffer(yourObject, function(arrayBuffer) {
alert(arrayBuffer.byteLength);
// 转回 object
FileConverter.ConvertToObject(arrayBuffer, function(yourObject) {
alert( JSON.stringify(yourObject) );
});
});
序列化格式说明:binarize.js 源自 Eiji Kitamura 的二进制序列化方案(Apache-2.0 许可,见 binarize.js 文件头注释)。它支持 NULL、UNDEFINED、STRING、NUMBER、BOOLEAN、ARRAY、OBJECT、各种 TypedArray、ArrayBuffer、Blob/File 等类型;每个值在打包时写入 type(1字节) + [length(2字节,仅数组/对象)] + byte_length(4字节) + payload,采用大端序(BIG_ENDIAN)。这保证了任意 JS 对象(包括 chunk 元数据)都能无损地在 DataChannel 上往返传输。
内部机制:getNextChunk 究竟做了什么
README 给出了一段揭示内部原理的伪代码(注意其中变量名 fileUUId / currentPosition 的写法与源码略有出入,以下按其语义复述):
// 该方法解释了 FileBufferReader 的内部机制
fbr.getNextChunk = function(fileUUId, callback) {
var fileChunks = fbr.chunks[fileUUID];
var currentPosition = fileChunks.currentPosition;
var nextChunk = fileChunks[currentPosition];
FileConverter.ConvertToArrayBuffer(nextChunk, function(buffer) {
// callback 收到两个参数:
// 1) 转换后的 buffer
// 2) "isLastChunk" 布尔值
callback(buffer, currentPosition == nextChunk.maxChunks);
});
};
// 你的代码这样调用上面的方法:
fbr.getNextChunks('file-uuid', function(buffer) {
webrtc.channel.send(buffer);
});
对照真实源码(FileBufferReader.js 第 30-102 行),getNextChunk 的实际行为是:
- 若传入的是带
currentPosition的对象(即接收端回传的元数据),则先用fileUUID.currentPosition更新游标,再取出uuid; - 无
userid时,递增fbr.chunks[fileUUID].currentPosition;有userid时,为每个用户维护独立的游标并递增; - 若
currentPosition已超出范围(找不到下一块),会删除该文件的 chunks 并回调一个{ chunkMissing: true, uuid, currentPosition }对象,供接收端触发chunkMissing清理逻辑; - 找到块后克隆一份(避免外部修改污染内部结构),多用户场景附加
remoteUserId,依次触发onBegin/onEnd/onProgress,最后ConvertToArrayBuffer后回调(buffer, isLastChunk)。
缺失块恢复:接收端在 onmessage 中检测到 chunk.chunkMissing 时,可调用 fbr.chunkMissing(chunk),它会清理接收端对应文件的残留状态(FileBufferReader.js 第 120-123 行),让发送方可以重新开始该文件的传输。
在 WebRTC-Experiment 生态中的位置
README 列出的典型应用是 RTCMultiConnection。仓库内的 RTCMultiConnection 目录将 FileBufferReader 与 RTCMultiConnection 的数据通道能力结合,提供了开箱即用的文件共享 demo(如 RTCMultiConnection/demos/file-sharing.html、RTCMultiConnection/demos/text-chat-file-sharing.html),以及 RTCMultiConnection/dev/FileProgressBarHandler.js 进度条处理器。换言之,FileBufferReader 专注于"分块 + 受控发送"这一层,而连接管理、信令、ICE 等交给上层库,二者分工清晰。
总结与选型建议
- 如果你的项目已经有一套 WebRTC DataChannel(或 Socket.io/WebSocket)通道,只想快速获得"文件分块 + 逐块确认 + 进度回调"能力,FileBufferReader 是轻量且贴合需求的选择。
- 由于分块全部驻留内存,请勿用于超大文件(README 明示 1GB 以上不可用);生产环境中如需大文件传输,应结合分片落盘或服务端中转方案。
- 使用前务必设置
binaryType = 'arraybuffer',并在收发两端按"readyForNextChunk 握手"协议协作;遵循 README 的建议,优先用原生input[type=file].onchange而非FileSelector触发文件选择。
许可证
FileBufferReader.js 基于 MIT 许可证发布(见 FileBufferReader/LICENSE 与 FileBufferReader/package.json),可免费用于任何商业或非商业产品。binarize.js 部分额外保留 Apache License 2.0 声明(见其文件头注释)。
【免费下载链接】WebRTC-Experiment
WebRTC, WebRTC and WebRTC. Everything here is all about WebRTC!!
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)