mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 问题(e2e 抓出,非猜测)
端到端跑 hear_audio 时模型答「老实说,这次我没法真的听到」,转而去数 WAV
字节。查日志:
tool hear_audio result: {"note":"音频已投给你——请在下一步直接聆听..."}
modal fallback: 0 block(s) transcribed, 0 skipped
(provider=llmsproxy vision=true audio=false)
真因是**配置缺口 + 描述说谎**叠加:
- 当前部署所有 LLM 源都是 audio=false(visionllm/llmsproxy 均如此)
- core.input_processing.audio.fallback_provider 为空 ⇒ 无回退源
- 于是内核既不能直视、也无法转写,只发一条「拿不到音频」的 notice
而工具描述写死「你会直接听到它」、note 写「请在下一步直接聆听」——
模型同时看到「你会听到」与「拿不到」,两句自相矛盾(这就是它转去数
WAV 字节的原因)。
## 为何不改代码改行为
插件在调用时**无法**得知当前模型是否支持音频,SDK 未暴露能力查询接口
(只有 LLMAPI 的 Chat/ReloadFromConfig)。所以插件没有能力「先判断再决定
要不要注块」。正确做法是:内核继续做它该做的诚实降级(已在 modalfallback
里实现),插件这边**不许替内核承诺**。
## 改了什么
- hear_audio 描述:从无条件承诺改为条件式(「若当前模型支持音频或配了回退源,
你会在下一步听到」),并明说:若都不支持,内核会明确告知拿不到(不会假装
听到),那时改用 transcribe_audio 或配置回退源
- handler 的 note:从「请直接聆听」改为「请确认是否真的收到;没收到就改用
transcribe_audio」
## 判据(可杀变异,3/3 实测)
- TestHearAudioDoesNotPromiseAudibility:描述不得含无条件承诺;必须指明
transcribe_audio 出路;必须如实说「不支持」
- TestHearAudioNoteIsHonest:note 不得说「直接聆听」;必须给替代方案
★ 判据自身修了一个 bug(变异测试抓出):原先用「下一个 RegisterTool」做段
终点,但 hear_audio 是**最后一个**注册 ⇒ 一路截到文件尾,把 handler 的 note
也吞进来,于是 note 里残留的 transcribe_audio 让「删掉描述出路」的变异逃逸。
改用 "p.handleHearAudio)" 作终点后该变异被杀。
变异实测:
F 描述改回无条件承诺 → FAIL ✓
G 描述删掉 transcribe_audio 出路 → FAIL ✓(修判据前曾逃逸)
H note 改回「直接聆听」 → FAIL ✓
I 描述删掉「不支持」说明 → FAIL ✓
18/18 通过;全量 homed 构建通过;二进制内已无旧假承诺、含新的诚实措辞。
Co-authored-by: JianFeeeee <JianFeeeee@users.noreply.gitcode.com>
582 lines
20 KiB
Go
582 lines
20 KiB
Go
// 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",
|
||
// ★ 不得无条件承诺「你会听到」(2026-10-11 e2e 抓出)。
|
||
//
|
||
// 实测:当前部署里**所有** LLM 源都是 audio=false,且
|
||
// core.input_processing.audio.fallback_provider 为空 ⇒ 内核既不能直视
|
||
// 也无法回退转写,只会发一条 notice 说「拿不到音频」。此前描述写死
|
||
// 「你会直接听到它」,与内核随后发的 notice 自相矛盾,模型据此困惑
|
||
// (实测它转而去数 WAV 字节)。描述必须如实说明可能拿不到。
|
||
Description: "【亲自听音频】把一段音频**原样投给你自己**去听。若当前模型支持音频" +
|
||
"(或配了可用的音频回退源),你会在下一步直接听到它。" +
|
||
"★若当前模型与回退源都不支持音频,内核会明确告诉你拿不到音频(不会假装听到)——" +
|
||
"那时请改用 transcribe_audio 拿文字转写,或请用户配置音频回退源" +
|
||
"(core.input_processing.audio.fallback_provider + 该源 audio=true)。" +
|
||
"来源三选一: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},
|
||
}})
|
||
// ★ note 同样不得承诺「你会听到」:插件在调用时无法得知当前模型/回退源
|
||
// 是否支持音频(SDK 未暴露能力查询),支持与否由内核在下一步判定。
|
||
// 这里只如实说「已投递」,并指明没收到时怎么办。
|
||
return map[string]interface{}{
|
||
"status": "ok",
|
||
"note": "音频已投递——请在下一步确认是否真的收到音频内容。" +
|
||
"若收到「不支持音频」之类的说明,说明当前模型与回退源都听不了," +
|
||
"请改用 transcribe_audio 拿文字转写。",
|
||
}, 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
|
||
}
|