news 2026/7/4 5:29:56

视频上传极速响应,全程零延迟

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
视频上传极速响应,全程零延迟

你的文件上传接口是否也要缓慢加载,是否也要等待上传的小圈圈,一圈一圈转个不停。下面我将介绍一种文件上传接口的实现技术,从此告别文件上传的长时间接口延迟,做到文件上传极速响应,全程零延迟,流畅拉升用户使用体验。


任务流程:

前端调取接口/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是磁盘文件

特性MultipartFileFile
存储位置内存或临时目录磁盘指定位置
生命周期请求结束后释放持久化,需手动删除
适用场景小文件快速处理大文件、需要反复读取
第三方库支持部分不支持几乎所有库都支持

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 数据万象好(多种截帧策略)低(按量计费)推荐,最省心
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/29 0:43:46

Linux2

1. 网络接口配置 1.1 修改网卡名称 在 Linux 系统中&#xff0c;ens32 是常见的网卡&#xff08;网络接口&#xff09;名称。如需同步网络接口&#xff0c;可执行以下操作&#xff1a;输入ip a查看接口&#xff0c;编辑 GRUB 配置文件&#xff1a;vim /boot/grub2/grub.cfg跳转…

作者头像 李华
网站建设 2026/6/29 0:29:04

7B 还是 32B,Strix Halo 上不同参数量模型的速度实测

7B 还是 32B&#xff1f;Strix Halo 上的真实速度对决 最近把主力机换成了搭载 AMD Strix Halo 架构的新本&#xff0c;最让我意外的不是游戏帧数&#xff0c;而是它跑本地大模型时的那种“从容感”。以前在轻薄本上跑 LLM&#xff0c;要么显存爆掉&#xff0c;要么速度慢得像 …

作者头像 李华
网站建设 2026/6/29 0:29:11

用云渲染好还是自己渲染好?不同项目该怎么选?

用云渲染好还是自己渲染好&#xff1f;这是很多设计师、动画师和工作室在出图前都会考虑的问题。其实两种方式各有优势&#xff0c;关键要看项目复杂度、交付时间、电脑配置和预算。如果场景简单&#xff0c;本地渲染更方便&#xff1b;如果任务紧急&#xff0c;云渲染效率更高…

作者头像 李华
网站建设 2026/6/29 0:29:10

UWB智能发球机,让训练更高效的运动伙伴

一、传统发球机为什么不够智能对于羽毛球、乒乓球、网球爱好者来说&#xff0c;发球机是日常训练的好帮手。它可以替代陪练&#xff0c;源源不断地送出固定落点的球&#xff0c;帮助练习者巩固动作、提升反应速度。但传统发球机也有一个明显短板&#xff1a;它只会按照预设程序…

作者头像 李华