你的文件上传接口是否也要缓慢加载,是否也要等待上传的小圈圈,一圈一圈转个不停。下面我将介绍一种文件上传接口的实现技术,从此告别文件上传的长时间接口延迟,做到文件上传极速响应,全程零延迟,流畅拉升用户使用体验。
任务流程:
前端调取接口/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 ──► 后端记录两个 URL2.后端接口
@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 截取首帧 ──► 上传封面到 COS2.后端处理
@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 数据万象 | 低 | 好(多种截帧策略) | 低(按量计费) | 推荐,最省心 |