一、概论

视频抽帧说明

当视频以图像列表(即预先抽取的视频帧)传入时,可通过fps参数告知模型视频帧之间的时间间隔,这能帮助模型更准确地理解事件的顺序、持续时间和动态变化。

  • DashScope SDK:

    支持通过 fps 参数指定原始视频的抽帧率,表示视频帧是每隔fps1​秒从原始视频中抽取的。该参数支持 Qwen2.5-VL、Qwen3-VL模型

  • OpenAI 兼容 SDK:

    不支持 fps 参数。模型将默认视频帧是按照每 0.5 秒一帧的频率抽取的。

简单来说:

        就是把提前抽好的视频帧(一张张图片)传给模型时,fps 参数是给模型的「时间标尺」—— 告诉模型这些帧之间的时间间隔,帮模型准确理解视频里事件的先后、持续时长和画面动态:

  1. 用 DashScope SDK(仅 Qwen2.5-VL/Qwen3-VL 支持):可自定义 fps,比如 fps=2,就是告诉模型 “这些帧是每隔 0.5 秒从原视频抽的”;
  2. 用 OpenAI 兼容 SDK:没法设 fps,模型默认按 “每 0.5 秒抽 1 帧” 来理解这些帧的时间间隔。

二、与视频的区别

        你可以把这两种方式理解为「模型帮你拆视频」和「你自己拆好视频给模型」的区别 —— 核心都是基于视频帧分析,但抽帧的主体、参数作用、灵活性、适用场景 完全不同,具体对比和解释如下:

维度 传入「视频文件」(如.mp4 URL) 传入「图像列表(视频帧)」
传入形式 直接传视频文件的 HTTPS URL / 本地文件 传按时间顺序排列的图片列表(帧)+ fps 参数
抽帧的主体 由 DashScope SDK / 模型自动完成抽帧 由你(开发者)提前手动 / 代码抽帧
fps 参数的作用 控制 SDK 的抽帧频率(比如 fps=2 → SDK 自动每 0.5 秒抽 1 帧) 告知模型 “这些帧是每隔 1/fps 秒抽的”(仅做时间标注,不控制抽帧)
数量限制的影响 SDK 抽帧后的总帧数需≤模型上限(如 2000),若超则报错 直接受数量限制(4~2000/512/80),传多了直接报错
灵活性 简单省心,但抽帧规则固定(只能按 fps 均匀抽) 灵活可控(可只抽关键帧,比如跳过无变化画面),但需自己做抽帧预处理
适用场景 快速调用、无需精准控制抽帧的普通场景 需精准控制抽帧(如高速运动视频、长视频只分析关键片段)

通俗举例

比如你要分析一段 10 秒的球赛视频:

  • 传视频文件:你只需要传.mp4 的 URL + fps=5(每 0.2 秒抽 1 帧),SDK 会自动抽 50 帧,模型基于这 50 帧分析;如果 50 帧≤模型上限(如 2000)就正常,超了就报错。
  • 传图像列表:你先手动抽取出球赛的 10 个关键帧(比如进球、传球的画面),按顺序组成列表,再传 fps=10(告诉模型 “这些帧是每隔 0.1 秒抽的”),模型基于这 10 帧分析(需≥4、≤模型上限)。

核心总结

  1. 最终模型都是基于「视频帧」分析内容,没有本质差异;
  2. 传视频文件:懒人选这个,不用处理抽帧,SDK 自动搞定,适合大部分普通场景;
  3. 传图像列表:精准控选这个,自己决定抽哪些帧(比如只留关键画面),适合需要降低算力、精准分析的场景;
  4. 数量限制对两者都生效:传视频文件时,SDK 抽帧后的总帧数不能超模型上限;传图像列表时,列表长度直接不能超上限(最少都要 4 帧,不然模型没法判断动态)。

简单说:传视频文件是「交钥匙工程」,传图像列表是「定制化工程」,前者省事儿,后者可控。

三、代码实现

        以下是基于官方「视频帧列表(图像列表)」示例,新增的完整接口代码(包含实体类、服务接口、实现类、控制器)

第一步:新增视频帧列表请求实体类 VideoFrameListRequest

import lombok.Data;
import javax.validation.constraints.*;
import java.util.List;

/**
 * 视频帧列表(图像列表)理解请求参数(通义千问VL)
 * 适用于:预先抽取视频帧为图片列表,传给模型分析
 * @author DELL
 */
@Data
public class VideoFrameListRequest {
    /** 视频帧图片URL列表(按播放顺序排列)
     * 数量限制:最少4张,最多按模型定(qwen3-vl-plus≤2000,Qwen2.5-VL≤512)
     */
    @NotEmpty(message = "视频帧图片URL列表不能为空")
    @Size(min = 4, message = "视频帧图片URL列表最少需要4张")
    private List<String> frameImageUrls;

    /** 抽帧频率fps(范围0.1~10,默认2.0)
     * 含义:告知模型「这些帧是每隔 1/fps 秒从原视频抽取的」(仅Qwen2.5-VL/Qwen3-VL支持)
     */
    @DecimalMin(value = "0.1", message = "fps最小值为0.1")
    @DecimalMax(value = "10", message = "fps最大值为10")
    private Float fps = 2.0f;

    /** 针对视频帧的提问文本 */
    @NotBlank(message = "提问文本不能为空")
    private String question;

    /** 模型名称(默认qwen3-vl-plus,仅Qwen2.5-VL/Qwen3-VL支持fps参数) */
    private String modelName = "qwen3-vl-plus";
}

第二步:新增视频帧列表服务接口 VideoFrameListService

import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.exception.UploadFileException;
import gzj.spring.ai.Request.VideoFrameListRequest;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

/**
 * 通义千问VL-视频帧列表(图像列表)理解服务接口
 * @author DELL
 */
public interface VideoFrameListService {

    /**
     * 视频帧列表理解-普通调用(非流式)
     * @param request 视频帧列表请求参数
     * @return 视频内容理解结果文本
     */
    String simpleFrameListCall(VideoFrameListRequest request) throws ApiException, NoApiKeyException, UploadFileException;

    /**
     * 视频帧列表理解-流式调用(SSE推送)
     * @param request 视频帧列表请求参数
     * @return SseEmitter 用于前端接收流式结果
     */
    SseEmitter streamFrameListCall(VideoFrameListRequest request);
}

第三步:新增视频帧列表服务实现类 VideoFrameListServiceImpl

package gzj.spring.ai.Service.ServiceImpl;

import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversation;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationParam;
import com.alibaba.dashscope.aigc.multimodalconversation.MultiModalConversationResult;
import com.alibaba.dashscope.common.MultiModalMessage;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.exception.UploadFileException;
import gzj.spring.ai.Request.VideoFrameListRequest;
import gzj.spring.ai.Service.VideoFrameListService;
import io.reactivex.Flowable;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.util.*;

/**
 * 视频帧列表(图像列表)理解服务实现(通义千问VL)
 * 适配官方「预先抽帧为图片列表」的调用方式
 * @author DELL
 */
@Slf4j
@Service
public class VideoFrameListServiceImpl implements VideoFrameListService {

    @Value("${dashscope.api-key}")
    private String apiKey;

    /**
     * 构建视频帧列表参数(video=图片URL列表 + fps)
     */
    private Map<String, Object> buildFrameListParams(VideoFrameListRequest request) {
        Map<String, Object> frameParams = new HashMap<>(2);
        // video字段传入图片URL列表(核心:和传视频文件的区别)
        frameParams.put("video", request.getFrameImageUrls());
        // 告知模型帧的时间间隔
        frameParams.put("fps", request.getFps());
        log.info("视频帧配置:fps={} → 模型将理解为「每隔{}秒抽取一帧」;帧数量={}",
                request.getFps(), 1/request.getFps(), request.getFrameImageUrls().size());
        return frameParams;
    }

    /**
     * 视频帧列表理解-普通调用(非流式)
     */
    @Override
    public String simpleFrameListCall(VideoFrameListRequest request) throws ApiException, NoApiKeyException, UploadFileException {
        MultiModalConversation conv = new MultiModalConversation();

        // 1. 构建用户消息(视频帧列表参数 + 提问文本)
        MultiModalMessage userMessage = MultiModalMessage.builder()
                .role(Role.USER.getValue())
                .content(Arrays.asList(
                        buildFrameListParams(request), // 视频帧+fps参数
                        Collections.singletonMap("text", request.getQuestion()) // 提问文本
                )).build();

        // 2. 构建API请求参数
        MultiModalConversationParam param = MultiModalConversationParam.builder()
                .apiKey(apiKey)
                .model(request.getModelName()) // 支持自定义模型(需为Qwen2.5-VL/Qwen3-VL系列)
                .messages(Arrays.asList(userMessage))
                .build();

        // 3. 同步调用API
        MultiModalConversationResult result = conv.call(param);

        // 4. 解析返回结果
        List<Map<String, Object>> content = result.getOutput().getChoices().get(0).getMessage().getContent();
        if (content != null && !content.isEmpty()) {
            return content.get(0).get("text").toString();
        }
        return "未获取到视频帧列表理解结果";
    }

    /**
     * 视频帧列表理解-流式调用(SSE推送)
     */
    @Override
    public SseEmitter streamFrameListCall(VideoFrameListRequest request) {
        // 视频帧分析耗时可能更长,超时设为60秒
        SseEmitter emitter = new SseEmitter(60000L);

        new Thread(() -> {
            MultiModalConversation conv = new MultiModalConversation();
            try {
                // 1. 构建用户消息
                MultiModalMessage userMessage = MultiModalMessage.builder()
                        .role(Role.USER.getValue())
                        .content(Arrays.asList(
                                buildFrameListParams(request),
                                Collections.singletonMap("text", request.getQuestion())
                        )).build();

                // 2. 构建流式请求参数
                MultiModalConversationParam param = MultiModalConversationParam.builder()
                        .apiKey(apiKey)
                        .model(request.getModelName())
                        .messages(Arrays.asList(userMessage))
                        .incrementalOutput(true) // 增量输出(流式)
                        .build();

                // 3. 流式调用API
                Flowable<MultiModalConversationResult> resultFlow = conv.streamCall(param);
                resultFlow.blockingForEach(item -> {
                    try {
                        List<Map<String, Object>> content = item.getOutput().getChoices().get(0).getMessage().getContent();
                        if (content != null && !content.isEmpty()) {
                            String text = content.get(0).get("text").toString();
                            // 推送流式数据到前端
                            emitter.send(SseEmitter.event().data(text));
                        }
                    } catch (Exception e) {
                        log.error("视频帧列表流式推送失败", e);
                        handleEmitterError(emitter, "流式推送失败:" + e.getMessage());
                    }
                });

                // 流式结束标记
                emitter.send(SseEmitter.event().name("complete").data("视频帧列表理解流结束"));
                emitter.complete();

            } catch (ApiException | NoApiKeyException | UploadFileException e) {
                log.error("视频帧列表流式调用API失败", e);
                handleEmitterError(emitter, "API调用失败:" + e.getMessage());
            } catch (Exception e) {
                log.error("视频帧列表流式调用未知异常", e);
                handleEmitterError(emitter, "系统异常:" + e.getMessage());
            }
        }).start();

        return emitter;
    }

    /**
     * 复用:统一处理SSE发射器异常
     */
    private void handleEmitterError(SseEmitter emitter, String errorMsg) {
        try {
            emitter.send(SseEmitter.event().name("error").data(errorMsg));
            emitter.completeWithError(new RuntimeException(errorMsg));
        } catch (Exception e) {
            log.error("处理发射器异常失败", e);
        }
    }
}

第四步:新增视频帧列表控制器 VideoController

直接在之前的视频接口里写入接口

import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.NoApiKeyException;
import com.alibaba.dashscope.exception.UploadFileException;
import gzj.spring.ai.Request.VideoFrameListRequest;
import gzj.spring.ai.Request.VideoRequest;
import gzj.spring.ai.Service.VideoFrameListService;
import gzj.spring.ai.Service.VideoService;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

/**
 * @author DELL
 */
@RestController
@RequestMapping("/api/multimodal/video")
@RequiredArgsConstructor
@CrossOrigin // 跨域支持(生产环境建议限定域名)
public class VideoController {

    private final VideoService videoService;
    private final VideoFrameListService videoFrameListService;
    @RequestMapping("/simple")
    public String simpleVideoCall(@RequestBody VideoRequest request) throws ApiException, NoApiKeyException, UploadFileException {
        return videoService.simpleVideoCall(request);
    }



    /**
     * 视频帧列表理解-普通调用(非流式)
     */
    @PostMapping("/simpleFrame")
    public String simpleFrameListCall(@Validated @RequestBody VideoFrameListRequest request)
            throws ApiException, NoApiKeyException, UploadFileException {
        return videoFrameListService.simpleFrameListCall(request);
    }

    /**
     * 视频帧列表理解-流式调用(SSE推送)
     */
    @PostMapping("/streamFrame")
    public SseEmitter streamFrameListCall(@Validated @RequestBody VideoFrameListRequest request) {
        return videoFrameListService.streamFrameListCall(request);
    }

}

总结

本次新增视频帧列表(图像列表)理解接口的代码核心总结如下:

一、代码结构与设计(延续原有风格)

新增 4 个核心模块,保持代码风格、异常处理、流式推送逻辑统一:

  1. 请求实体(VideoFrameListRequest):封装帧列表核心参数(按顺序的图片 URL 列表、fps、提问文本、模型名),强化参数校验(帧列表最少 4 张、fps 范围 0.1~10),贴合模型底层限制;
  2. 服务接口(VideoFrameListService):定义普通 / 流式两种调用方式,与原有视频接口设计一致;
  3. 服务实现(VideoFrameListServiceImpl):对齐官方示例,将video字段设为图片 URL 列表(而非视频文件 URL),fps 参数仅用于 “告知模型帧的时间间隔”,并适配 60 秒流式超时;
  4. 控制器(VideoFrameListController):暴露/api/multimodal/video-frame接口,支持普通 / 流式调用,加入参数校验和跨域支持。

二、与原有 “视频文件接口” 的核心差异

维度 原有视频文件接口 新增视频帧列表接口
video字段值 单个视频文件 URL(如.mp4) 按播放顺序的图片 URL 列表
fps 参数作用 控制 SDK 自动抽帧频率 仅告知模型 “帧的时间间隔”(无抽帧动作)
预处理要求 无需预处理(SDK 自动抽帧) 需提前抽帧并按顺序整理图片 URL

三、关键注意事项

  1. 帧列表必须严格按视频播放顺序排列,否则模型会误判事件顺序;
  2. fps 仅对 Qwen2.5-VL/Qwen3-VL 系列模型生效,其他模型传入无效;
  3. 帧数量需符合模型上限(qwen3-vl-plus≤2000、Qwen2.5-VL≤512),超量会报参数错误;
  4. 图片 URL 需为公开可访问的 HTTPS 链接,确保模型能读取。

四、核心价值

实现了 “开发者自定义抽帧” 的视频理解能力,相比自动抽帧更灵活(可只传关键帧),适配需要精准控制抽帧的场景(如高速运动视频、长视频关键片段分析)。

四、示例

参数

{
  "frameImageUrls": [
    "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241108/xzsgiz/football1.jpg",
    "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241108/tdescd/football2.jpg",
    "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241108/zefdja/football3.jpg",
    "https://help-static-aliyun-doc.aliyuncs.com/file-manage-files/zh-CN/20241108/aedbqh/football4.jpg"
  ],
  "fps": 2.0,
  "question": "描述这个视频的具体过程",
  "modelName": "qwen3-vl-plus"
}

结果

如果觉得这份修改实用、总结清晰,别忘了动动小手点个赞👍,再关注一下呀~ 后续还会分享更多 AI 接口封装、代码优化的干货技巧,一起解锁更多好用的功能,少踩坑多提效!🥰 你的支持就是我更新的最大动力,咱们下次分享再见呀~🌟

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐