视频上传极速响应,全程零延迟
你的文件上传接口是否也要缓慢加载,是否也要等待上传的小圈圈,一圈一圈转个不停。下面我将介绍一种文件上传接口的实现技术,从此告别文件上传的长时间接口延迟,做到文件上传极速响应,全程零延迟,流畅拉升用户使用体验。
任务流程:
前端调取接口/uploadFileMp4Submit请求访问,上传File到服务器,此时接口不对文件做任何处理,只保存一条任务数据到数据库,记录是谁上传的视频(任务所有者)以及任务的状态task_state,然后直接把上传视频的任务丢给异步进程去做,给前端返回一个请求结果。至此前端的交互完成,响应在10ms以内,做到无感上传。
异步进程先将File文件解析为MultipartFile(为什么要解析为MultipartFile可见下文),然后利用VideoUtils 生成封面并上传 COS存储桶,同时修改task的任务状态task_state为封面已上传,接着,把视频上传到COS存储桶,至此整个任务结束。
1.为什么要先传封面再传视频?先截帧上传封面,前端可以先拿到封面展示,提高用户体验。
2.可以将上传封面和视频放在不同的进程异步上传吗?当然可以,只是在这里没必要。
任务都交给后端服务器异步处理了,前端怎么知道有没有上传成功呢?
这个时候就可以用上我们的task任务记录了,我们可以再写一个接口,专门查询这个任务的状态,让前端轮询这个接口,当监听到状态为已完成时前端便知道我们上传成功了。
那前端怎么展示这个视频呢,不能等到上传成功之后才给展示吧,这样用户体验感相当的差了。
展示给用户的时候,其实他们只会去看个封面,前面说到视频截帧封面上传到存储桶,当上传到存储桶之后,前端便可以进行展示。我们可以在上传文件的时候就把封面和视频的路径给写死,然后传先返回给前端,同时把这两个上传路径传给异步任务,让他们上传到存储桶的路径必须按照这个来。这个时候只要当封面和视频成功上传之后,前端就可以查看了。
那还有一个问题,视频可以先不给用户看,那封面什么时候展示呢,一直不展示封面也不太好吧
针对这个问题,封面也就是图片,上传到存储桶很快,几十毫秒便可完成,费时间的是截帧生成封面,需要一秒钟左右(大文件,小得文件也就几十毫秒)和File文件转为MultipartFile文件的时间(大约一秒钟左右,具体取决于文件的大小),也就是从选择完视频,点击上传开始,到前端展示只需要一秒钟左右的延迟,后续视频可以慢慢上传,反正前端已经可以展示视频上传中了。那这一秒的空白时间有没有优化空间呢?当然,我们可以让前端先展示一个预先准备好的图片,然后,在轮询监听task的时候,当监听到封面上传成功之后,前端立刻把视频封面换成该视频的封面,这样就可以无缝衔接,提高用户体验。
废话不多说,先上代码!!!
1、暴露给前端的接口(/uploadFileMp4Submit)
@PostMapping("/uploadFileMp4Submit")
public R<?> submitUploadMp4(@RequestParam("file") MultipartFile multipartFile,
@RequestParam(value = "dir", required = false) String dir,
@RequestParam(value = "userId", required = false) String userId) {
if (multipartFile == null || multipartFile.isEmpty()) {
return R.fail("请选择要上传的视频文件");
}
userId = StringUtils.isEmpty(userId) ? SecurityUtils.getOID() : userId;
dir = StringUtils.isEmpty(dir) ? "syb" : dir;
String originalName = multipartFile.getOriginalFilename();
if (StringUtils.isEmpty(originalName)) {
return R.fail("文件名不能为空");
}
// 校验格式
String suffix = originalName.substring(originalName.lastIndexOf(".") + 1);
if (!isValidVideoFormat(suffix)) {
return R.fail("不支持的格式: " + suffix);
}
String taskId = UUID.randomUUID().toString();
// ========== 预生成固定路径 ==========
String dateStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String fileUuid = UUID.randomUUID().toString();
String coverUuid = UUID.randomUUID().toString();
// 固定路径(前端可以直接用)
String videoKey = dir + "/" + dateStr + "/" + fileUuid + "." + suffix;
String coverKey = dir + "/covers/" + coverUuid + ".jpg";
//存储桶域名路径
String cosUrl = "https://ttc.com/" + videoKey;
String coverUrl = "https://ttc.com/" + coverKey;
try {
taskMapper.insert(taskId, userId, originalName, cosUrl, coverUrl);
} catch (Exception e) {
log.error("创建任务失败", e);
return R.fail("创建任务失败");
}
// 启动异步任务
videoUploadAsyncService.doUploadAsync(taskId, multipartFile, videoKey, coverKey, originalName);
Map<String, Object> result = new HashMap<>();
result.put("taskId", taskId);
result.put("cosUrl", cosUrl); // 固定路径,上传完成后自动生效
result.put("coverUrl", coverUrl); // 固定路径,上传完成后自动生效
result.put("message", "视频上传任务已提交,正在后台处理");
return R.ok(result);
}
2、文件格式校验(isValidVideoFormat)
防止非法文件上传。
private boolean isValidVideoFormat(String suffix) {
if (StringUtils.isEmpty(suffix)) {
return false;
}
Set<String> allowedFormats = new HashSet<>(Arrays.asList(
"mp4", "avi", "mov", "flv", "wmv", "mkv", "webm", "m4v"
));
return allowedFormats.contains(suffix.toLowerCase());
}
3、异步文件上传(VideoUploadAsyncService)
@Service
@Slf4j
public class VideoUploadAsyncService {
@Autowired
private UploadTaskMapper taskMapper;
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
//存储桶文件名称
private static final String BUCKET_NAME = "syb-1328371015";
//存储访问域名
private static final String CDN_DOMAIN = "https://ttc.com/";
// 硬编码初始化 COSClient
//存储桶私钥
String secretId = "AKIDw14Karx6DXp";
String secretKey = "9GINeyeimx8HQf";
COSCredentials cred = new BasicCOSCredentials(secretId, secretKey);
Region region = new Region("ap-shanghai");
ClientConfig clientConfig = new ClientConfig(region);
COSClient cosClient = new COSClient(cred, clientConfig);
@Async("uploadExecutor")
public void doUploadAsync(String taskId, MultipartFile multipartFile,
String videoKey, String coverKey, String originalName){
long startTime = System.currentTimeMillis();
File videoFile = null;
try {
videoFile = multipartFileToFile(multipartFile);
} catch (IOException e) {
taskMapper.updateFailed(taskId, "转临时文件失败");
return; // 必须 return,否则 videoFile 未初始化
}
try {
taskMapper.updateProcessing(taskId);
log.info("开始异步处理, taskId: {}, fileName: {}", taskId, originalName);
// ========== 1. 生成并上传封面 ==========
boolean s1 = generateAndUploadCover(videoFile, coverKey);
if(s1){
taskMapper.updateSuccess1(taskId);
}else {
taskMapper.updateFailed(taskId, "封面上传失败失败");
}
// ========== 2. 上传视频 ==========
PutObjectRequest putRequest = new PutObjectRequest(BUCKET_NAME, videoKey, videoFile);
cosClient.putObject(putRequest);
//taskMapper.updateSuccess2(taskId);
String duration = formatDuration(videoFile);
// ========== 3. 更新成功 ==========
long costTime = System.currentTimeMillis() - startTime;
taskMapper.updateSuccess(taskId, duration);
log.info("视频上传成功, taskId: {}, url: {}, coverUrl: {}, costTime: {}ms",
taskId, videoKey, coverKey, costTime);
} catch (Exception e) {
long costTime = System.currentTimeMillis() - startTime;
log.error("视频上传失败, taskId: {}, costTime: {}ms", taskId, costTime, e);
taskMapper.updateFailed(taskId, "上传失败: " + e.getMessage());
} finally {
safeDelete(videoFile);
}
}
/**
* 使用 VideoUtils 生成封面并上传 COS
*/
private boolean generateAndUploadCover(File videoFile, String coverKey) {
String tempDir = System.getProperty("java.io.tmpdir") + "video_temp/";
new File(tempDir).mkdirs();
File thumbnailFile = new File(tempDir + UUID.randomUUID() + ".jpg");
FFmpegFrameGrabber grabber = null;
try {
// 1. 截图
grabber = new FFmpegFrameGrabber(videoFile);
grabber.start();
// 获取第一帧(跳过可能的黑屏)
Frame frame = null;
for (int i = 0; i < 10; i++) {
frame = grabber.grabImage();
if (frame != null && frame.image != null) {
break;
}
}
if (frame == null || frame.image == null) {
log.warn("无法获取视频帧, file: {}", videoFile.getName());
return false;
}
Java2DFrameConverter converter = new Java2DFrameConverter();
BufferedImage image = converter.convert(frame);
// 保存为 jpg
if (!ImageIO.write(image, "jpg", thumbnailFile)) {
log.warn("保存缩略图失败: {}", thumbnailFile.getAbsolutePath());
return false;
}
grabber.stop();
// 2. 上传到 COS 指定路径
PutObjectRequest coverRequest = new PutObjectRequest(BUCKET_NAME, coverKey, thumbnailFile);
cosClient.putObject(coverRequest);
log.info("封面上传成功, coverKey: {}", coverKey);
return true;
} catch (Exception e) {
log.warn("截图或上传失败: {}", e.getMessage());
return false;
} finally {
safeDelete(thumbnailFile);
if (grabber != null) {
try {
grabber.close();
} catch (Exception ignored) {}
}
}
}
private void safeDelete(File file) {
if (file != null && file.exists() && !file.delete()) {
file.deleteOnExit();
}
}
private String formatDuration(File file) {
MultimediaObject multimediaObject = new MultimediaObject(file);
MultimediaInfo result = null;
try {
result = multimediaObject.getInfo();
} catch (EncoderException e) {
e.printStackTrace();
}
Long durationInSeconds = result.getDuration() / 1000;
return durationInSeconds.intValue() + "";
}
public static File multipartFileToFile(MultipartFile mulFile) throws IOException {
InputStream ins = mulFile.getInputStream();
String fileName = mulFile.getOriginalFilename();
String prefix = getFileNameNoEx(fileName) + UUID.fastUUID();
String suffix = "." + getExtensionName(fileName);
File toFile = File.createTempFile(prefix, suffix);
OutputStream os = new FileOutputStream(toFile);
int bytesRead = 0;
byte[] buffer = new byte[8192];
while ((bytesRead = ins.read(buffer, 0, 8192)) != -1) {
os.write(buffer, 0, bytesRead);
}
os.close();
ins.close();
return toFile;
}
public static String getFileNameNoEx(String filename) {
if ((filename != null) && (filename.length() > 0)) {
int dot = filename.lastIndexOf('.');
if ((dot > -1) && (dot < (filename.length()))) {
return filename.substring(0, dot);
}
}
return filename;
}
public static String getExtensionName(String filename) {
if ((filename != null) && (filename.length() > 0)) {
int dot = filename.lastIndexOf('.');
if ((dot > -1) && (dot < (filename.length() - 1))) {
return filename.substring(dot + 1);
}
}
return filename;
}
}
3.1、封面截帧、上传(generateAndUploadCover)
private boolean generateAndUploadCover(File videoFile, String coverKey) {
String tempDir = System.getProperty("java.io.tmpdir") + "video_temp/";
new File(tempDir).mkdirs();
File thumbnailFile = new File(tempDir + UUID.randomUUID() + ".jpg");
FFmpegFrameGrabber grabber = null;
try {
// 1. 截图
grabber = new FFmpegFrameGrabber(videoFile);
grabber.start();
// 获取第一帧(跳过可能的黑屏)
Frame frame = null;
for (int i = 0; i < 10; i++) {
frame = grabber.grabImage();
if (frame != null && frame.image != null) {
break;
}
}
if (frame == null || frame.image == null) {
log.warn("无法获取视频帧, file: {}", videoFile.getName());
return false;
}
Java2DFrameConverter converter = new Java2DFrameConverter();
BufferedImage image = converter.convert(frame);
// 保存为 jpg
if (!ImageIO.write(image, "jpg", thumbnailFile)) {
log.warn("保存缩略图失败: {}", thumbnailFile.getAbsolutePath());
return false;
}
grabber.stop();
// 2. 上传到 COS 指定路径
PutObjectRequest coverRequest = new PutObjectRequest(BUCKET_NAME, coverKey, thumbnailFile);
cosClient.putObject(coverRequest);
log.info("封面上传成功, coverKey: {}", coverKey);
return true;
} catch (Exception e) {
log.warn("截图或上传失败: {}", e.getMessage());
return false;
} finally {
safeDelete(thumbnailFile);
if (grabber != null) {
try {
grabber.close();
} catch (Exception ignored) {}
}
}
}
3.2、获取视频时长(formatDuration)
private String formatDuration(File file) {
MultimediaObject multimediaObject = new MultimediaObject(file);
MultimediaInfo result = null;
try {
result = multimediaObject.getInfo();
} catch (EncoderException e) {
e.printStackTrace();
}
Long durationInSeconds = result.getDuration() / 1000;
return durationInSeconds.intValue() + "";
}
3.3、MultipartFile 转换为临时 File(multipartFileToFile)
public static File multipartFileToFile(MultipartFile mulFile) throws IOException {
InputStream ins = mulFile.getInputStream();
String fileName = mulFile.getOriginalFilename();
String prefix = getFileNameNoEx(fileName) + UUID.fastUUID();
String suffix = "." + getExtensionName(fileName);
File toFile = File.createTempFile(prefix, suffix);
OutputStream os = new FileOutputStream(toFile);
int bytesRead = 0;
byte[] buffer = new byte[8192];
while ((bytesRead = ins.read(buffer, 0, 8192)) != -1) {
os.write(buffer, 0, bytesRead);
}
os.close();
ins.close();
return toFile;
}
3.3.1、 为什么要转:MultipartFile 是内存/临时文件,File 是磁盘文件
| 特性 | MultipartFile | File |
|---|---|---|
| 存储位置 | 内存或临时目录 | 磁盘指定位置 |
| 生命周期 | 请求结束后释放 | 持久化,需手动删除 |
| 适用场景 | 小文件快速处理 | 大文件、需要反复读取 |
| 第三方库支持 | 部分不支持 | 几乎所有库都支持 |
3.3.2、什么情况下需要转 File
1. 第三方库只接受 File 参数
// FFmpeg 处理视频
ProcessBuilder pb = new ProcessBuilder("ffmpeg", "-i", file.getAbsolutePath(), ...);
// 阿里云 OSS 上传
ossClient.putObject(bucket, key, file); // 接受 File
// 腾讯云 COS 上传
cosClient.upload(key, file); // 接受 File
// PDF 处理
PDFParser parser = new PDFParser(new FileInputStream(file));
很多 SDK 的 API 设计就是接收
File,不接收InputStream。
2. 需要多次读取文件内容
// MultipartFile 的 InputStream 只能读一次
InputStream is1 = multipartFile.getInputStream(); // 第一次读取
// is1 读完后,multipartFile.getInputStream() 可能为空或已读完
// File 可以反复打开读取
new FileInputStream(file); // 第一次
new FileInputStream(file); // 第二次,没问题
3. 大文件处理,避免内存溢出
// MultipartFile 默认存在内存,大文件会撑爆内存
byte[] bytes = multipartFile.getBytes(); // 100MB 文件 = 100MB 内存
// 转成 File 后,可以流式处理
FileInputStream fis = new FileInputStream(file);
// 逐块读取,内存占用小
4. 异步处理,请求结束后文件还在
@PostMapping("/upload")
public R<?> upload(MultipartFile file) {
// 请求结束后,multipartFile 会被清理
// 转成 File 保存到磁盘,异步任务可以继续用
File tempFile = multipartFileToFile(file);
// 异步处理,请求立即返回
CompletableFuture.runAsync(() -> {
// 这里 multipartFile 可能已经失效,但 tempFile 还在
processVideo(tempFile);
});
return R.ok();
}
4、查询任务状态接口(uploadFileMp4/status/)
@GetMapping("uploadFileMp4/status/{taskId}")
public R<?> getUploadStatus(@PathVariable String taskId) {
Map<String, Object> task = taskMapper.selectById(taskId);
if (task == null) {
return R.fail("任务不存在");
}
return R.ok(task);
}
5、任务执行时间细分
16:26:41.051 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,706] - 步骤1-空校验: 0ms
16:26:41.051 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,709] - 步骤2-SecurityUtils: 0ms
16:26:41.051 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,723] - 步骤3-格式校验: 0ms
16:26:41.054 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,738] - 步骤4-生成路径: 3ms
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@dc49ab6] was not registered for synchronization because synchronization is not active
JDBC Connection [com.alibaba.druid.proxy.jdbc.ConnectionProxyImpl@2c47135c] will not be managed by Spring
==> Preparing: INSERT INTO upload_task (id, user_id, original_name, cos_url, cover_url ,status, created_at) VALUES (?, ?, ?, ?, ?, 0, NOW())
==> Parameters: ac86e835-8be1-40de-9864-0fef967e72ce(String), 1491109363820003328(String), 3ea20459-b262-460d-9b58-809af5192507 (1).mp4(String), https://sybfile.sybmiaoda.com/syb/20260626/6060d24c-6f6d-413d-b6e6-8c4fbbfbec45.mp4(String), https://sybfile.sybmiaoda.com/syb/covers/2be4539f-bdd9-409d-b246-1bece71623e6.jpg(String)
<== Updates: 1
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@dc49ab6]
16:26:41.057 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,742] - 步骤5-数据库插入: 1ms
16:26:41.060 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,753] - 步骤6-启动异步: 5ms
16:26:41.060 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,760] - 步骤7-组装返回: 0ms
16:26:41.060 [http-nio-9201-exec-6] INFO c.r.s.c.FileController - [submitUploadMp4,762] - 总耗时: 9ms
Creating a new SqlSession

在服务器上执行时间只有9ms,但接口请求时间却需要1423ms,这是因为,文件上传要需要经过我们的后端服务器,再上传。
5.1、什么情况下必须走服务器?
1. 需要服务器处理文件内容
| 场景 | 说明 |
|---|---|
| 视频转码 | 需要服务器用 FFmpeg 转码不同清晰度 |
| 图片压缩/裁剪 | 生成缩略图、加水印 |
| 文件格式校验 | 需要读取文件头验证真实格式 |
| 病毒扫描 | 上传前杀毒 |
| 内容审核 | AI 鉴黄、鉴暴等 |
| 提取元数据 | 读取视频时长、分辨率等 |
2. 需要服务器记录或控制
| 场景 | 说明 |
|---|---|
| 权限校验 | 判断用户是否有上传权限 |
| 配额限制 | 检查用户剩余空间 |
| 计费统计 | 按流量/容量计费 |
| 日志审计 | 记录谁上传了什么 |
| 敏感词过滤 | 文件名、内容过滤 |
3. 存储后端不支持直传
| 场景 | 说明 |
|---|---|
| 私有存储 | 自建 MinIO、NAS 等没有预签名功能 |
| 旧系统兼容 | 老系统只支持服务端上传 |
| 多存储聚合 | 需要服务器决定存哪个后端 |
高阶!前端直传 COS
上面的方法必须要经过后端服务器,浪费时间。
那有没有不经过后端的方案?
有的兄弟,有的!!!
后端:提供临时签名
@PostMapping("/getUploadUrl")
public R<?> getUploadUrl(@RequestParam("fileName") String fileName,
@RequestParam("fileSize") long fileSize) {
// 生成唯一路径
String dateStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"));
String fileUuid = UUID.randomUUID().toString();
String suffix = fileName.substring(fileName.lastIndexOf(".") + 1);
String videoKey = "syb/" + dateStr + "/" + fileUuid + "." + suffix;
String coverKey = "syb/covers/" + UUID.randomUUID().toString() + ".jpg";
// COS 临时上传 URL(有效期5分钟)
String uploadUrl = cosClient.getPresignedUploadUrl(videoKey, 5 * 60);
// 预创建任务记录
String taskId = UUID.randomUUID().toString();
taskMapper.insert(taskId, SecurityUtils.getOID(), fileName,
"https://ttc.com/" + videoKey,
"https://ttc.com/" + coverKey);
Map<String, Object> result = new HashMap<>();
result.put("taskId", taskId);
result.put("uploadUrl", uploadUrl); // 前端直传到这里
result.put("videoUrl", "https://ttc.com/" + videoKey);
result.put("coverUrl", "https://ttc.com/" + coverKey);
return R.ok(result);
}
前端:直传 COS
// 1. 获取上传URL
const res = await fetch('/api/getUploadUrl', {
method: 'POST',
body: JSON.stringify({fileName: 'video.mp4', fileSize: 10240000})
});
const { uploadUrl, videoUrl, taskId } = await res.json();
// 2. 直接上传到COS(不经过你的服务器)
await fetch(uploadUrl, {
method: 'PUT',
body: file, // File 对象
headers: {
'Content-Type': file.type
}
});
// 3. 通知后端上传完成
await fetch('/api/confirmUpload', {
method: 'POST',
body: JSON.stringify({taskId, status: 'SUCCESS'})
});
这样前后端搭配,让前端直接上传文件到COS存储桶,就不用再让文件先传到后端浪费时间了。省时有省力。省事!
请求回调
现在遇到与上面同样的问题,前端怎么知道上传是否成功。下面我为您提供了两个方法。
方式一:COS 回调通知
// 生成带回调的上传 URL
String uploadUrl = cosClient.getPresignedUploadUrl(videoKey, 5 * 60,
CosCallback.builder()
.url("https://your-api.com/cos/callback") // 你的回调地址
.body("{\"taskId\":\"" + taskId + "\"}")
.build()
);
后端接收回调
@PostMapping("/cos/callback")
public void cosCallback(@RequestBody CosCallbackBody body) {
String taskId = body.getTaskId();
String cosStatus = body.getStatus(); // SUCCESS / FAILED
// 更新任务状态
taskMapper.updateStatus(taskId,
"SUCCESS".equals(cosStatus) ? TaskStatus.SUCCESS : TaskStatus.FAILED);
// 如果成功,触发后续处理(如转码)
if ("SUCCESS".equals(cosStatus)) {
videoProcessService.startProcess(taskId);
}
}
优点:最可靠,不受前端网络影响。
方式二:前端轮询查询
// 1. 上传
await fetch(uploadUrl, { method: 'PUT', body: file });
// 2. 轮询查询状态
let retries = 0;
const maxRetries = 30; // 最多轮询30次
const timer = setInterval(async () => {
const res = await fetch(`/api/taskStatus?taskId=${taskId}`);
const { status } = await res.json();
if (status === 'SUCCESS') {
clearInterval(timer);
showSuccess();
} else if (status === 'FAILED') {
clearInterval(timer);
showError();
} else if (++retries >= maxRetries) {
clearInterval(timer);
showTimeout();
}
}, 2000); // 每2秒轮询一次
后端提供查询接口
@GetMapping("/taskStatus")
public R<?> getTaskStatus(@RequestParam String taskId) {
UploadTask task = taskMapper.selectById(taskId);
return R.ok(Map.of("status", task.getStatus()));
}
完整流程
前端 你的服务器 COS
│ │ │
│──► GET /getUploadUrl ──►│ │
│◄─── uploadUrl, taskId ──┤ │
│ │ │
│──► PUT uploadUrl ───────┼────────────────────►│
│ │ │
│◄─── 200 OK ─────────────┼◄─────────────────────┤
│ │ │
│──► GET /taskStatus ────►│ │
│◄──── status: PENDING ───┤ │
│ │ │
│ [轮询...] │ │
│ │ │
│◄──── status: SUCCESS ───┤◄─── COS 回调 ────────┤
│ │ │
视频封面怎么处理?
方案 1:前端上传封面(用户自选)
1.流程
前端选择视频 + 封面图 ──► 同时直传 COS ──► 后端记录两个 URL
2.后端接口
@PostMapping("/getUploadUrls")
public R<?> getUploadUrls(@RequestParam("hasCover") boolean hasCover,
@RequestParam("fileName") String fileName,
@RequestParam("coverName") String coverName) {
String videoKey = generateVideoKey(fileName);
String coverKey = generateCoverKey(coverName);
// 两个预签名 URL
String videoUploadUrl = cosClient.getPresignedUploadUrl(videoKey);
String coverUploadUrl = cosClient.getPresignedUploadUrl(coverKey);
String taskId = UUID.randomUUID().toString();
Map<String, Object> result = new HashMap<>();
result.put("taskId", taskId);
result.put("videoUploadUrl", videoUploadUrl);
result.put("coverUploadUrl", hasCover ? coverUploadUrl : null);
result.put("videoUrl", "https://sybfile.sybmiaoda.com/" + videoKey);
result.put("coverUrl", "https://sybfile.sybmiaoda.com/" + coverKey);
// 预创建任务
taskMapper.insert(taskId, videoKey, coverKey, hasCover ? 1 : 0);
return R.ok(result);
}
3.前端
// 同时上传视频和封面
await Promise.all([
fetch(videoUploadUrl, { method: 'PUT', body: videoFile }),
hasCover ? fetch(coverUploadUrl, { method: 'PUT', body: coverFile }) : Promise.resolve()
]);
方案 2:后端自动生成封面(视频首帧)
1.流程
前端上传视频 ──► COS ──► 后端回调 ──► FFmpeg 截取首帧 ──► 上传封面到 COS
2.后端处理
@Service
public class VideoProcessService {
@Autowired
private CosClient cosClient;
@Async
public void generateCover(String taskId, String videoKey) {
try {
// 1. 从 COS 下载视频到本地临时文件
File tempVideo = cosClient.downloadToTemp(videoKey);
// 2. FFmpeg 截取首帧
File coverFile = new File(tempVideo.getParent(), "cover.jpg");
ProcessBuilder pb = new ProcessBuilder(
"ffmpeg", "-i", tempVideo.getAbsolutePath(),
"-ss", "00:00:00.001", // 第1毫秒
"-vframes", "1",
"-q:v", "2",
coverFile.getAbsolutePath()
);
pb.inheritIO();
Process process = pb.start();
process.waitFor();
// 3. 上传封面到 COS
String coverKey = videoKey.replace(".mp4", ".jpg")
.replace("/videos/", "/covers/");
cosClient.upload(coverKey, coverFile);
// 4. 更新任务
String coverUrl = "https://sybfile.sybmiaoda.com/" + coverKey;
taskMapper.updateCoverUrl(taskId, coverUrl);
} catch (Exception e) {
log.error("生成封面失败: taskId={}", taskId, e);
}
}
}
方案 3:COS 智能封面(腾讯云能力)
1.腾讯云 COS 支持数据万象(CI)自动截取封面:
视频上传到 COS ──► 配置 CI 规则 ──► 自动生成封面缩略图
配置方式:
在 COS 控制台开启数据万象
配置视频截帧规则
上传视频后自动触发
2.获取封面 URL:
// COS 数据万象截帧 URL 格式
String coverUrl = "https://sybfile.sybmiaoda.com/" + videoKey
+ "?ci-process=snapshot" // 截帧处理
+ "&time=1" // 第1秒
+ "&format=jpg" // 输出格式
+ "&width=480" // 宽度
+ "&height=270"; // 高度
无需后端处理,直接拼接 URL 即可!
方案对比
| 方案 | 实现难度 | 用户体验 | 成本 | 适用场景 |
|---|---|---|---|---|
| 前端上传封面 | 低 | 好(用户自选) | 低 | 用户需要自定义封面 |
| 后端 FFmpeg 截帧 | 中 | 一般(首帧可能黑屏) | 中(服务器资源) | 需要自定义逻辑 |
| COS 数据万象 | 低 | 好(多种截帧策略) | 低(按量计费) | 推荐,最省心 |
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)