Files
HomeAgent/internal/plugins/seemedia/plugin.go
JianFeeeee 16309c3307 feat(seemedia): 多模态直视插件——让模型一手看图/看视频/听音频 (#6)
* feat(seemedia): 新增多模态直视插件,让模型一手看图/看视频/听音频

## 动机

现有 multimodal(describe_image / transcribe_audio / ocr_image)走的是**二手**路:
插件自己起一次 VLM 子调用,把结果**文字**喂回主模型。省 token、能入记忆正文、
主模型非视觉也能用,但模型看到的是「别人转述的描述」,细节丢失不可逆。

缺的是**一手**路:让模型主动把原始像素/帧/音频投给**自己**去判读。本插件补上。

## 工具(三模态,用户决策 image+video+audio)

- `see_image`:path/digest/url → image_url 块,经 SetToolBlocks 直投主模型
- `see_video`:ffmpeg 抽帧(默认 6 帧,上限 10,对齐内核 modalfallback 约束)
  → 多张 image_url 块一起投;探时长成功走均匀定位抽帧,失败退单遍 thumbnail
- `hear_audio`:path/digest/url → audio_url 块直投

三者都 `ParallelSafe:false`:SetToolBlocks 写的是「当前 call」的块,并发会串台。

## 与 multimodal 并存(用户决策 coexist)

职责分工,不互相取代:
- 要省 token / 要把结论写进记忆 / 主模型不支持该模态 → describe_image(二手文字)
- 要模型亲自判读细节(看清截图、逐帧看操作) → see_*(一手原始媒体)

## 媒体来源(用户决策 path+digest+url 全支持)

- path:设备/工具回传的落盘文件(local_screensee、browser_screenshot、mc 截图…)
- digest:用户上传已入 CAS 的媒体,sdk.Media().Get 取回,不在内核↔插件间传 base64
- url:http(s) 直链;图/音频原样透传(不下载、不入 CAS),视频交 ffmpeg 原生读 http

## 直接进记忆(记忆载体已块化,天然打通)

seemedia 经 SetToolBlocks 注入的块,内核 task.go captureBlockMedia 处**无条件**
落进 CAS 并转成一等记忆块挂到本轮 ContextEvent(「无论直视还是回退,媒体本体都该
进记忆」)。所以一手看到的图/帧**同时**进了记忆,之后能按多模态向量被
doc_query / memory_recall 召回。本插件不写任何入库代码,复用既有块化链路。

★ 边界(刻意取舍):captureBlockMedia 只对 data URL 入库,http(s) URL 被跳过
(不下载、不入记忆、不算向量,规避 SSRF/超时)。即 path/digest 来源可召回,
url 来源只是模型当场看到但不进记忆。要让 http 媒体长期可召回,先落盘再传 path。

## 形态:内置插件(用户决策 builtin)

与 multimodal 并排 internal/plugins/seemedia,经 all.go init() 注册。代价是延续
「内核进程内读任意本地文件」这个 a02d8b00 本想消除的张力——但 multimodal 已是
内置且同样读 path,保持一致比单独走子进程沙箱更不易让人困惑;日后收紧应两者同搬。

## 判据(可杀变异,实测)

plugin_test.go:
- url 透传且拒 file://(去掉 isHTTPURL 校验 → FAIL)
- path 优先于 digest、url 优先于 path,且 path 命中时不碰 CAS
- 无来源报错且信息可操作(指明 path/digest/url)
- 文件错误带路径、空文件明说「空」
- clampFrames 把帧数夹到 [1,10]、非正回默认(超限不夹 → FAIL)
- 三工具不得声明 ParallelSafe:true(防 SetToolBlocks 串台)

9/9 单测通过;本机 ffmpeg/ffprobe 已就位;全插件 + homed(-tags onnxruntime)
构建通过;internal/plugins 装配冒烟通过。

* test(seemedia): 补集成/压力/变异测试(16 判据,5 变异全杀)

上一个提交只带 9 个纯逻辑单测(来源解析、clamp、MIME)。抽帧是本插件唯一
真正驱动外部进程、且会并发的部分,必须真跑 ffmpeg 压一压:

- 真视频 → 请求 N 帧就拿到 N 帧,每帧校验 PNG 魔数(1/3/6/10 四档)
- ffprobe 置空 → 走单遍兜底仍能拿到帧
- 损坏/非视频文件 → 如实报错,不 panic、不返半成品
- 已取消的 ctx → ffmpeg 必须立刻失败(取消传播真生效)
- 并发 16 路抽帧 → 不串台、不泄漏临时目录(前后计数比对)
- digest 视频 → 真 CAS 取字节落临时文件,cleanup 后必须删掉
- 静态判据:所有子进程必须 exec.CommandContext,不得裸 exec.Command

为什么超时判据从「计时」改成「已取消 ctx」:合成视频抽帧本来就快(~0.1s),
「1ns 超时」和「忽略超时」在计时上分不出——实测这种变异杀不掉,是判据设计
缺陷。改用已取消 ctx 的行为判据(CommandContext 在 ctx 已取消时 Start 即失败)
后,忽略 ctx 的变异被杀。

变异实测(5/5 全 FAIL,还原后全绿):
  A clampFrames 超限不夹        → TestClampFrames FAIL
  B url 分支去掉 isHTTPURL      → TestResolveMediaURL_URLPassthrough FAIL
  C cleanup 不删临时文件         → TestResolveVideoInput_DigestCleanup FAIL
  D grabFrameAt 忽略 ctx         → TestFrameGrabHonorsCanceledContext FAIL
  E 抽帧数写死 1                 → TestExtractFrames_RealVideoExactCount FAIL

16/16 通过;`-tags onnxruntime` 全量 homed 构建通过,三工具在册。

---------

Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
2026-10-11 13:06:23 +08:00

568 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package seemedia 让模型**主动一手**去看图/看视频/听音频。
//
// # 与 multimodal 插件的分工(2026-10-11 新增)
//
// `multimodal`(describe_image / transcribe_audio / ocr_image)走的是**二手**路:
// 插件自己起一次 VLM 子调用,把结果**文字**喂回主模型。省 token、能入记忆正文、
// 主模型非视觉也能用,但模型看到的是「别人转述的描述」,细节丢失不可逆。
//
// 本插件走的是**一手**路:把图/帧/音频的字节**直接投给主模型**,经
// `PluginSDK.SetToolBlocks` → 内核在紧随的 user message 里带上 image_url/audio_url
// 块(task.go `ConsumeToolBlocks`)。模型是**自己看到**原始像素,而不是读描述。
//
// 两者刻意并存(用户 2026-10-11 决策 coexist):
// - 要省 token、要把结论写进记忆、主模型不支持该模态 → describe_image(二手)
// - 要模型亲自判读细节(看清一张截图、逐帧看一段操作) → see_image/see_video(一手)
//
// # 直视不可用时会怎样(内核已兜底,插件不必关心)
//
// 主模型不支持视觉/音频时,内核 `prepareToolBlocks` 会把这些块走回退链批量
// 转写成文字再并入上下文(modalfallback.go),**不会**把 image_url 静默塞给不支持
// 的网关(那会被剥离后返回 200,模型答「没看到图」而内核以为成功)。所以本插件
// 只管「把字节交给内核」,支不支持、要不要回退由内核决定。
//
// # 媒体来源(用户决策 path + digest + url 三者都支持)
//
// path —— 设备/工具回传的落盘文件(local_screensee、browser_screenshot、mc 截图…)
// digest —— 用户上传已入 CAS 的媒体,用 sdk.Media().Get 取回,不在内核与插件间传 base64
// url —— http(s) 直链;图/音频原样透传给模型(不下载,不入 CAS,交给上游拉取),
// 视频则交给 ffmpeg 直接读取(ffmpeg 原生支持 http 输入)
//
// # 媒体入记忆(天然打通,本插件无需自己写库)
//
// seemedia 经 SetToolBlocks 注入的块,内核在 task.go `captureBlockMedia` 处**无条件**
// 落进 CAS 并转成一等记忆块挂到本轮 ContextEvent(「无论直视还是回退转写,媒体
// 本体都该进记忆」)。所以记忆载体块化之后,一手看到的图/帧**同时**进了记忆,
// 之后能按多模态向量被 doc_query / memory_recall 召回。本插件不写任何入库代码。
//
// ★ 但有一个边界:`captureBlockMedia` 只对 **data URL** 做 CAS 入库,http(s) URL
// 会被跳过(不下载、不入记忆、不算向量——避免下载引入超时/SSRF)。因此:
//
// path / digest 来源 → 转 data URL → 进记忆块(可召回)
// url(http 直链) → 原样透传给模型看 → 模型看得到,但**不进记忆**
//
// 这是刻意取舍,不是缺陷:要让一份 http 媒体长期可召回,应先把它落盘再传 path。
//
// # 为什么是内置插件而不是子进程插件(用户决策 builtin)
//
// 与 multimodal 并排,形态一致、最省心。代价是延续了「内核进程内读任意本地文件」
// 这个 a02d8b00 本想消除的张力——但既然 multimodal 已是内置且同样读 path,这里
// 保持一致比单独走子进程沙箱更不易让人困惑。若日后要收紧,两个插件应一起搬。
package seemedia
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/JianFeeeee/HomeAgent/internal/plugin"
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
)
const (
// defaultVideoFrames 是 see_video 默认抽取的帧数。
defaultVideoFrames = 6
// maxVideoFrames 是硬上限。对齐内核 modalfallback 的「see_video 一次最多 10 帧」
// 约束:帧越多上游越慢、越易超单请求体积限制,回退转写也只处理前若干块。
maxVideoFrames = 10
)
func init() {
plugin.RegisterPluginMeta("seemedia", "多模态直视", "See Media")
plugin.RegisterFactory("seemedia", func(name string, config map[string]interface{}) (sdk.Plugin, error) {
return New(name), nil
})
}
// Plugin 提供 see_image / see_video / hear_audio:把媒体字节直接投给主模型。
type Plugin struct {
name string
sdk *sdk.PluginSDK
videoFrames int
timeout time.Duration
ffmpeg string // ffmpeg 可执行路径(空=未探测到,see_video 会以 error 上抛)
ffprobe string
}
// New 构造插件。
func New(name string) *Plugin { return &Plugin{name: name} }
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
p.videoFrames = defaultVideoFrames
p.timeout = 120 * time.Second
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "video_frames", Default: strconv.Itoa(defaultVideoFrames), Type: "int",
DisplayName: "see_video 抽帧数",
Description: fmt.Sprintf("see_video 默认从视频里均匀抽取多少帧投给模型(1-%d)。"+
"帧越多模型越能看清过程变化,但上游越慢、越易超体积限制。", maxVideoFrames),
Category: "seemedia",
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "timeout_seconds", Default: "120", Type: "int",
DisplayName: "媒体处理超时(秒)",
Description: "单次取媒体/抽帧的时间上限。",
Category: "seemedia",
})
p.loadSettings()
// ffmpeg/ffprobe 只在 see_video 时需要;探测结果缓存,缺失时在工具描述里说清。
p.ffmpeg, _ = exec.LookPath("ffmpeg")
p.ffprobe, _ = exec.LookPath("ffprobe")
srcParam := map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "媒体文件路径(设备/工具回传的落盘文件,如 screensee / browser_screenshot 的截图)。" +
"path / digest / url 三者给其一。",
},
"digest": map[string]interface{}{
"type": "string",
"description": "媒体在内容寻址库(CAS)里的 digest(用户上传已入库的媒体)。",
},
"url": map[string]interface{}{
"type": "string",
"description": "媒体的 http(s) 直链。图片/音频原样交给模型,视频交给 ffmpeg 读取。",
},
}
if err := s.RegisterTool("see_image", sdk.ToolDef{
Name: "see_image",
Description: "【亲自看图】把一张图片**原样投给你自己**去看——你会在下一步直接看到这张图的真实画面," +
"而不是别人转述的文字描述。需要判读截图、看清图里的细节/布局/颜色时用它。" +
"(只想要一段文字描述、或你本身不支持视觉时,用 describe_image 更省。)" +
"来源三选一:path / digest / url。",
Parameters: srcObject(srcParam),
ParallelSafe: false,
}, p.handleSeeImage); err != nil {
return err
}
videoDesc := "【亲自看视频】从一段视频里均匀抽若干帧**原样投给你自己**去看,你会直接看到这些画面。" +
"适合判读一段操作录屏、监控片段、动图的过程变化。来源三选一:path / digest / url。" +
"可选 frames 指定抽帧数(1-" + strconv.Itoa(maxVideoFrames) + ",默认 " + strconv.Itoa(defaultVideoFrames) + ")。"
if p.ffmpeg == "" {
videoDesc += " ★本机未检测到 ffmpeg,调用会失败;请先安装 ffmpeg。"
}
if err := s.RegisterTool("see_video", sdk.ToolDef{
Name: "see_video",
Description: videoDesc,
Parameters: p.videoParams(srcParam),
ParallelSafe: false,
}, p.handleSeeVideo); err != nil {
return err
}
if err := s.RegisterTool("hear_audio", sdk.ToolDef{
Name: "hear_audio",
Description: "【亲自听音频】把一段音频**原样投给你自己**去听,你会直接听到它," +
"而不是读一段转写文字。需要判断语气/音乐/非语音声音时用它。" +
"(只想要文字转写、或你本身不支持音频时,用 transcribe_audio 更省。)" +
"来源三选一:path / digest / url。",
Parameters: srcObject(srcParam),
ParallelSafe: false,
}, p.handleHearAudio); err != nil {
return err
}
return nil
}
func (p *Plugin) Stop() error { return nil }
func srcObject(props map[string]interface{}) map[string]interface{} {
return map[string]interface{}{"type": "object", "properties": props}
}
func (p *Plugin) videoParams(src map[string]interface{}) map[string]interface{} {
props := map[string]interface{}{}
for k, v := range src {
props[k] = v
}
props["frames"] = map[string]interface{}{
"type": "integer",
"description": fmt.Sprintf("可选:抽取帧数(1-%d,默认 %d)。", maxVideoFrames, defaultVideoFrames),
}
return srcObject(props)
}
func (p *Plugin) loadSettings() {
s := p.sdk.Settings()
if s == nil {
return
}
if v, _ := s.Get("video_frames"); v != nil {
if n := toInt(v); n > 0 {
p.videoFrames = clampFrames(n)
}
}
if v, _ := s.Get("timeout_seconds"); v != nil {
if n := toInt(v); n > 0 {
p.timeout = time.Duration(n) * time.Second
}
}
}
// ── 工具处理 ───────────────────────────────────────────────
func (p *Plugin) handleSeeImage(args map[string]interface{}) (interface{}, error) {
url, err := p.resolveMediaURL(args, "image")
if err != nil {
return nil, err
}
p.sdk.SetToolBlocks([]sdk.ContentBlock{{
Type: "image_url",
ImageURL: &sdk.ImageURL{URL: url, Detail: "auto"},
}})
return map[string]interface{}{
"status": "ok",
"note": "图片已投给你——请在下一步直接查看画面本身(这不是文字描述)。",
}, nil
}
func (p *Plugin) handleHearAudio(args map[string]interface{}) (interface{}, error) {
url, err := p.resolveMediaURL(args, "audio")
if err != nil {
return nil, err
}
p.sdk.SetToolBlocks([]sdk.ContentBlock{{
Type: "audio_url",
AudioURL: &sdk.AudioURL{URL: url},
}})
return map[string]interface{}{
"status": "ok",
"note": "音频已投给你——请在下一步直接聆听(这不是转写文字)。",
}, nil
}
func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error) {
if p.ffmpeg == "" {
return nil, fmt.Errorf("本机未安装 ffmpeg,无法从视频抽帧;请先安装 ffmpeg(apt install ffmpeg 或等价命令)")
}
n := p.videoFrames
if v, ok := args["frames"]; ok {
if k := toInt(v); k > 0 {
n = clampFrames(k)
}
}
input, cleanup, err := p.resolveVideoInput(args)
if err != nil {
return nil, err
}
defer cleanup()
frames, err := p.extractFrames(input, n)
if err != nil {
return nil, err
}
if len(frames) == 0 {
return nil, fmt.Errorf("未能从视频抽到任何帧(文件可能损坏或不是视频)")
}
blocks := make([]sdk.ContentBlock, 0, len(frames))
for _, f := range frames {
dataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(f)
blocks = append(blocks, sdk.ContentBlock{
Type: "image_url",
ImageURL: &sdk.ImageURL{URL: dataURL, Detail: "auto"},
})
}
p.sdk.SetToolBlocks(blocks)
return map[string]interface{}{
"status": "ok",
"frames": len(blocks),
"note": fmt.Sprintf("已抽取 %d 帧并投给你——请在下一步直接查看这些画面,"+
"按时间顺序理解过程变化。", len(blocks)),
}, nil
}
// ── 来源解析 ───────────────────────────────────────────────
// resolveMediaURL 把 path/digest/url 解析成可直接放进 image_url/audio_url 的字符串。
//
// url → 原样返回(http 直链,交给上游拉取;不下载、不入 CAS)
// path → 读本地文件,转 data URL
// digest → 从 CAS 取回字节,转 data URL
//
// kind 仅用于在扩展名/元信息都缺失时猜一个兜底 MIME(image 或 audio)。
func (p *Plugin) resolveMediaURL(args map[string]interface{}, kind string) (string, error) {
path := strings.TrimSpace(argStr(args, "path"))
digest := strings.TrimSpace(argStr(args, "digest"))
rawURL := strings.TrimSpace(argStr(args, "url"))
switch {
case rawURL != "":
if !isHTTPURL(rawURL) {
return "", fmt.Errorf("url 必须是 http(s) 直链,实际: %q", rawURL)
}
return rawURL, nil
case path != "":
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("读取文件失败 %s: %w", path, err)
}
if len(b) == 0 {
return "", fmt.Errorf("文件为空: %s", path)
}
return dataURL(mimeByExtOr(path, kind), b), nil
case digest != "":
if p.sdk.Media() == nil {
return "", fmt.Errorf("内容寻址库不可用,无法按 digest 取媒体")
}
b, err := p.sdk.Media().Get(digest)
if err != nil {
return "", fmt.Errorf("按 digest 取媒体失败 %s: %w", digest, err)
}
mime := fallbackMIME(kind)
if info, serr := p.sdk.Media().Stat(digest); serr == nil && info.MIME != "" {
mime = info.MIME
}
return dataURL(mime, b), nil
}
return "", fmt.Errorf("没有可处理的媒体。请传 path(设备/工具回传的文件,如 screensee 的截图)、" +
"digest(CAS 里的媒体),或 url(http(s) 直链)三者之一")
}
// resolveVideoInput 为 see_video 解析出一个 ffmpeg 能读的输入。
//
// url → 原样交给 ffmpeg(ffmpeg 原生支持 http 输入),无需下载
// path → 直接用该路径
// digest → 从 CAS 取字节落到临时文件(ffmpeg 需要可寻址的输入)
//
// 返回的 cleanup 负责删掉临时文件(path/url 情形是 no-op)。
func (p *Plugin) resolveVideoInput(args map[string]interface{}) (input string, cleanup func(), err error) {
noop := func() {}
path := strings.TrimSpace(argStr(args, "path"))
digest := strings.TrimSpace(argStr(args, "digest"))
rawURL := strings.TrimSpace(argStr(args, "url"))
switch {
case rawURL != "":
if !isHTTPURL(rawURL) {
return "", noop, fmt.Errorf("url 必须是 http(s) 直链,实际: %q", rawURL)
}
return rawURL, noop, nil
case path != "":
if st, e := os.Stat(path); e != nil {
return "", noop, fmt.Errorf("视频文件不可读 %s: %w", path, e)
} else if st.Size() == 0 {
return "", noop, fmt.Errorf("视频文件为空: %s", path)
}
return path, noop, nil
case digest != "":
if p.sdk.Media() == nil {
return "", noop, fmt.Errorf("内容寻址库不可用,无法按 digest 取视频")
}
b, e := p.sdk.Media().Get(digest)
if e != nil {
return "", noop, fmt.Errorf("按 digest 取视频失败 %s: %w", digest, e)
}
f, e := os.CreateTemp("", "seemedia-*.video")
if e != nil {
return "", noop, fmt.Errorf("创建临时文件失败: %w", e)
}
if _, e := f.Write(b); e != nil {
f.Close()
os.Remove(f.Name())
return "", noop, fmt.Errorf("写入临时视频失败: %w", e)
}
f.Close()
name := f.Name()
return name, func() { os.Remove(name) }, nil
}
return "", noop, fmt.Errorf("没有可处理的视频。请传 path、digest 或 url 之一")
}
// ── 抽帧 ───────────────────────────────────────────────────
// extractFrames 从视频里均匀抽 n 帧,返回每帧的 PNG 字节。
//
// 策略:先用 ffprobe 探时长,再在 duration*(i+0.5)/n 处逐帧快速定位(-ss 在 -i 前,
// 关键帧 seek,快)。探时长失败(直播流/损坏头)时退回单遍 fps 滤镜抽前 n 帧。
func (p *Plugin) extractFrames(input string, n int) ([][]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
defer cancel()
if dur := p.probeDuration(ctx, input); dur > 0 {
var frames [][]byte
for i := 0; i < n; i++ {
ts := dur * (float64(i) + 0.5) / float64(n)
b, err := p.grabFrameAt(ctx, input, ts)
if err != nil || len(b) == 0 {
continue // 单帧失败不致命:有几帧投几帧
}
frames = append(frames, b)
}
if len(frames) > 0 {
return frames, nil
}
// 定位抽帧全失败 → 落到单遍兜底
}
return p.grabFramesFPS(ctx, input, n)
}
func (p *Plugin) probeDuration(ctx context.Context, input string) float64 {
if p.ffprobe == "" {
return 0
}
out, err := exec.CommandContext(ctx, p.ffprobe,
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
input,
).Output()
if err != nil {
return 0
}
d, _ := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if d < 0 {
return 0
}
return d
}
// grabFrameAt 在 ts 秒处抓 1 帧 PNG,输出到 stdout。
func (p *Plugin) grabFrameAt(ctx context.Context, input string, ts float64) ([]byte, error) {
cmd := exec.CommandContext(ctx, p.ffmpeg,
"-nostdin", "-loglevel", "error",
"-ss", strconv.FormatFloat(ts, 'f', 3, 64),
"-i", input,
"-frames:v", "1",
"-f", "image2pipe", "-c:v", "png",
"pipe:1",
)
var buf bytes.Buffer
cmd.Stdout = &buf
if err := cmd.Run(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// grabFramesFPS 单遍抽前 n 帧(兜底,用于探不到时长的流)。
func (p *Plugin) grabFramesFPS(ctx context.Context, input string, n int) ([][]byte, error) {
dir, err := os.MkdirTemp("", "seemedia-frames-")
if err != nil {
return nil, fmt.Errorf("创建临时目录失败: %w", err)
}
defer os.RemoveAll(dir)
cmd := exec.CommandContext(ctx, p.ffmpeg,
"-nostdin", "-loglevel", "error",
"-i", input,
"-vf", "thumbnail",
"-frames:v", strconv.Itoa(n),
filepath.Join(dir, "f-%03d.png"),
)
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffmpeg 抽帧失败: %w", err)
}
entries, _ := filepath.Glob(filepath.Join(dir, "f-*.png"))
var frames [][]byte
for _, e := range entries {
if b, err := os.ReadFile(e); err == nil && len(b) > 0 {
frames = append(frames, b)
}
}
return frames, nil
}
// ── 纯助手 ─────────────────────────────────────────────────
func clampFrames(n int) int {
if n < 1 {
return defaultVideoFrames
}
if n > maxVideoFrames {
return maxVideoFrames
}
return n
}
func isHTTPURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
func dataURL(mime string, b []byte) string {
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
}
func mimeByExtOr(path, kind string) string {
if m := mimeByExt(path); m != "" {
return m
}
return fallbackMIME(kind)
}
func fallbackMIME(kind string) string {
if kind == "audio" {
return "audio/wav"
}
return "image/png"
}
func mimeByExt(p string) string {
switch strings.ToLower(filepath.Ext(p)) {
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
case ".bmp":
return "image/bmp"
case ".wav":
return "audio/wav"
case ".mp3":
return "audio/mpeg"
case ".m4a", ".aac":
return "audio/aac"
case ".ogg", ".oga":
return "audio/ogg"
case ".flac":
return "audio/flac"
}
return ""
}
func argStr(args map[string]interface{}, key string) string {
if v, ok := args[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func toInt(v interface{}) int {
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case string:
i, _ := strconv.Atoi(strings.TrimSpace(n))
return i
}
return 0
}