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


       任务流程:

        前端调取接口/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 数据万象低好(多种截帧策略)低(按量计费)推荐,最省心

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐