mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
块 union 化前三阶段已让块自带负载、回填存量字节。本提交拆掉 CAS 本身: `internal/memory/media/`(2100 行)+ `QueryMediaScored`(第二套召回) + `media_refs`/`ref_count` 引用账本 + `internal/sdk/media.go`(MediaAPI) + `SDKConfig.Media` / `SetMediaStore` 接线。删完 CAS 才真正不存在。 ## 为何可删(不是"先删了再说") - **MediaAPI 在生产早已是死的**:`SDKConfig.Media` 自86572d13起从未被设置, `sdk.Media()` 恒为 nil,插件 digest 路径一直走"内容寻址库不可用"分支。 删的是死代码,不是活路径。 - **第二套召回确无调用方**:`QueryMediaScored` 仅被 media 包自身的测试引用, 跨包引用为 0(块召回走 `RecallBlocks`)。 - **heldMediaDigests 补丁**(crossmodal 靠它防召回"无处可归的媒体") 已在86572d13删除 —— 两套存储才需要的那种补丁,随存储一起没了。 ## 替代路径:块引用取代 digest 插件取媒体从 `sdk.Media().Get(digest)` 改为 `sdk.Memory().MediaBytes(ref)`: - **新增 `MemoryAPI.MediaBytes(ref) (data, mime, ok, err)`**(内置接口,非 pubsdk): 按块引用(完整块 ID 或唯一前缀)取字节;`ok=false` 而非 error —— "这个引用没有媒体"是调用方能处理的常态。 - **seemedia / multimodal**:`digest` 参数语义改为**媒体块引用**,工具描述同步。 path/url 两条来源不变(生产实际只用这两条)。 - **webui 知识上传**:媒体字节不再 Put 进 CAS 再拿 digest,而是作为 `KnowledgeMediaRef.Data` 随引用携带,由 knowledge.Write 落进条目目录 (与 content.md 同生共灭,Remove 的 RemoveAll 天然清掉)。 - **回填命令去依赖**:cmd/homed-media-backfill 不再 import 待删的 media 包, 改为直接读**冻结的历史布局** blobs/<d[:2]>/<d[2:]> 并自校验 sha256。 依赖待删代码的迁移工具会在删包那刻失效 —— 迁移必须比被迁移的东西活得久。 ## 判据(全部做过变异测试) - `TestMemoryBytes_ByBlockRef`:完整 ID / 唯一前缀可取回且逐字节一致; 不存在→ok=false;过短前缀(歧义)→ok=false;无负载块→ok=false;空引用不 panic。 变异(不校验 ok,把不存在当有)当场被 3 条断言抓住。 - `TestE2EMediaBytesAgainstBackfilledDB`(-tags e2e,读真回填库): 10/10 块经 MediaBytes 取回的字节与块 data 的 sha256 完全一致。 - `TestKnowledgeUploadedMediaRetrievable` 重写为**条目目录自带字节**判据 (不再是"向存储按 digest 读回");`TestKnowledgeUploadMediaNeedsNoStore` 改为"上传无外部存储依赖也必须成功"(旧的 503 语义已随 CAS 消失)。 ## 静态确认 `QueryMediaScored` / `internal/memory/media` import / `MediaAPI` / `sdk.Media()` / `SetMediaStore` / `heldMediaDigests` 全仓均为 0 处 (仅剩 medialoop.go 一句历史注释)。 回归:memory / sdk / agent-core / knowledge / plugin / webui / seemedia / multimodal 八包全绿;全仓生产编译 GREEN。
252 lines
7.9 KiB
Go
252 lines
7.9 KiB
Go
package seemedia
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/JianFeeeee/HomeAgent/internal/memory"
|
||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
// 真跑 ffmpeg 的集成/压力/变异测试;无 ffmpeg 时自动跳过。
|
||
//
|
||
// 单测(plugin_test.go)钉纯逻辑(来源解析、clamp、MIME);这里钉真实行为:
|
||
// - 抽帧真能从一段真视频拿到正确数量的合法 PNG
|
||
// - 探不到时长时走单遍兜底仍能拿到帧
|
||
// - 损坏输入如实报错、不 panic
|
||
// - 已取消的 ctx 必须让 ffmpeg 立刻失败(取消传播真生效)
|
||
// - 并发 16 路抽帧不串台、不泄漏临时目录
|
||
// - 块引用视频走真记忆库 → 临时文件 → cleanup 删文件
|
||
|
||
// sdkWithVideo 把一段视频字节写成**真记忆块**(内容派生 ID),返回带记忆面的
|
||
// PluginSDK 与真实块引用(块 ID 或其唯一前缀,与生产同一取字节路径)。
|
||
//
|
||
// ★ CAS 淘汰后不再有媒体存储可 Put:字节进块(MemoryBlock.Data),
|
||
// 插件用 sdk.Memory().MediaBytes(ref) 取回 —— 这正是测试要钉的路径。
|
||
func sdkWithVideo(t *testing.T, raw []byte) (*sdk.PluginSDK, string) {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
db, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Cleanup(func() { db.Close() })
|
||
|
||
block := memory.MemoryBlock{
|
||
ID: memory.MediaBlockID("video/mp4", raw),
|
||
Modality: memory.BlockVideo,
|
||
Data: raw,
|
||
MIME: "video/mp4",
|
||
Size: int64(len(raw)),
|
||
Tool: "test",
|
||
}
|
||
if err := db.PutMemoryBlocks([]memory.MemoryBlock{block}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return sdk.New("seemedia", sdk.SDKConfig{
|
||
Memory: sdk.NewGraphMemoryWithPlugin("seemedia", db),
|
||
}), block.ID
|
||
}
|
||
|
||
func mkTestVideo(t *testing.T, dur int) string {
|
||
t.Helper()
|
||
ff, err := exec.LookPath("ffmpeg")
|
||
if err != nil {
|
||
t.Skip("无 ffmpeg,跳过集成测试")
|
||
}
|
||
out := filepath.Join(t.TempDir(), "clip.mp4")
|
||
cmd := exec.Command(ff, "-nostdin", "-loglevel", "error",
|
||
"-f", "lavfi", "-i", fmt.Sprintf("testsrc=duration=%d:size=320x240:rate=10", dur),
|
||
"-pix_fmt", "yuv420p", out, "-y")
|
||
if err := cmd.Run(); err != nil {
|
||
t.Skipf("ffmpeg 造测试视频失败(环境问题,跳过): %v", err)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func newProbedPlugin(t *testing.T) *Plugin {
|
||
t.Helper()
|
||
p := &Plugin{videoFrames: defaultVideoFrames, timeout: 60 * time.Second}
|
||
p.ffmpeg, _ = exec.LookPath("ffmpeg")
|
||
p.ffprobe, _ = exec.LookPath("ffprobe")
|
||
if p.ffmpeg == "" {
|
||
t.Skip("无 ffmpeg,跳过集成测试")
|
||
}
|
||
return p
|
||
}
|
||
|
||
func isPNG(b []byte) bool {
|
||
return len(b) >= 8 &&
|
||
b[0] == 0x89 && b[1] == 'P' && b[2] == 'N' && b[3] == 'G' &&
|
||
b[4] == 0x0D && b[5] == 0x0A && b[6] == 0x1A && b[7] == 0x0A
|
||
}
|
||
|
||
func countSeemediaTemp() int {
|
||
frames, _ := filepath.Glob(filepath.Join(os.TempDir(), "seemedia-frames-*"))
|
||
vids, _ := filepath.Glob(filepath.Join(os.TempDir(), "seemedia-*.video"))
|
||
return len(frames) + len(vids)
|
||
}
|
||
|
||
// ① 真视频 → 请求 N 帧就拿到 N 帧,且每帧都是合法 PNG。
|
||
func TestExtractFrames_RealVideoExactCount(t *testing.T) {
|
||
p := newProbedPlugin(t)
|
||
video := mkTestVideo(t, 10)
|
||
|
||
for _, n := range []int{1, 3, 6, 10} {
|
||
frames, err := p.extractFrames(video, n)
|
||
if err != nil {
|
||
t.Fatalf("n=%d 抽帧失败: %v", n, err)
|
||
}
|
||
if len(frames) != n {
|
||
t.Errorf("请求 %d 帧,实得 %d 帧", n, len(frames))
|
||
}
|
||
for i, f := range frames {
|
||
if !isPNG(f) {
|
||
t.Errorf("n=%d 第 %d 帧不是合法 PNG(%d 字节)", n, i, len(f))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ② 探不到时长(ffprobe 置空)时走单遍兜底,仍能拿到帧。
|
||
func TestExtractFrames_FallbackPathWorks(t *testing.T) {
|
||
p := newProbedPlugin(t)
|
||
video := mkTestVideo(t, 6)
|
||
|
||
p.ffprobe = "" // 强制走兜底:模拟探时长失败
|
||
frames, err := p.extractFrames(video, 4)
|
||
if err != nil {
|
||
t.Fatalf("兜底路径抽帧失败: %v", err)
|
||
}
|
||
if len(frames) == 0 {
|
||
t.Fatal("兜底路径一帧都没抽到")
|
||
}
|
||
for i, f := range frames {
|
||
if !isPNG(f) {
|
||
t.Errorf("兜底第 %d 帧不是 PNG", i)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ③ 损坏/非视频文件:如实报错,不 panic、不返回半成品。
|
||
func TestExtractFrames_CorruptInput(t *testing.T) {
|
||
p := newProbedPlugin(t)
|
||
bad := filepath.Join(t.TempDir(), "notvideo.mp4")
|
||
if err := os.WriteFile(bad, []byte("this is definitely not a video"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
frames, err := p.extractFrames(bad, 3)
|
||
if err == nil && len(frames) > 0 {
|
||
t.Errorf("损坏输入不应抽出帧,却得到 %d 帧", len(frames))
|
||
}
|
||
}
|
||
|
||
// ④ 取消传播真生效:已取消的 ctx 必须让 ffmpeg 立刻失败,而不是照跑。
|
||
//
|
||
// 为什么不计时测 p.timeout:合成视频抽帧本来就快(~0.1s),「1ns 超时」和
|
||
// 「忽略超时」在计时上分不出(此前实测这种变异未被杀,正是判据设计缺陷)。
|
||
// 真正要防的是「完全不用 ctx → 无限挂」,用已取消 ctx 的行为判据来钉:
|
||
// exec.CommandContext 在 ctx 已取消时 Start 即失败;换成 exec.Command 则照跑。
|
||
func TestFrameGrabHonorsCanceledContext(t *testing.T) {
|
||
p := newProbedPlugin(t)
|
||
video := mkTestVideo(t, 4)
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
cancel() // 立即取消
|
||
|
||
b, err := p.grabFrameAt(ctx, video, 1.0)
|
||
if err == nil {
|
||
t.Fatalf("已取消的 ctx 必须让抽帧失败,却成功返回 %d 字节(ffmpeg 未被 ctx 约束)", len(b))
|
||
}
|
||
}
|
||
|
||
// ⑤ 压力:并发 16 路抽帧,各自独立临时目录,不串帧、不泄漏临时目录。
|
||
func TestExtractFrames_ConcurrentNoLeak(t *testing.T) {
|
||
p := newProbedPlugin(t)
|
||
video := mkTestVideo(t, 8)
|
||
|
||
tmpBefore := countSeemediaTemp()
|
||
|
||
const workers = 16
|
||
var wg sync.WaitGroup
|
||
errs := make(chan error, workers)
|
||
for i := 0; i < workers; i++ {
|
||
wg.Add(1)
|
||
go func(id int) {
|
||
defer wg.Done()
|
||
n := (id % 10) + 1
|
||
frames, err := p.extractFrames(video, n)
|
||
if err != nil {
|
||
errs <- fmt.Errorf("worker %d: %w", id, err)
|
||
return
|
||
}
|
||
if len(frames) == 0 {
|
||
errs <- fmt.Errorf("worker %d: 0 帧", id)
|
||
}
|
||
}(i)
|
||
}
|
||
wg.Wait()
|
||
close(errs)
|
||
for err := range errs {
|
||
t.Error(err)
|
||
}
|
||
|
||
time.Sleep(200 * time.Millisecond)
|
||
tmpAfter := countSeemediaTemp()
|
||
if tmpAfter > tmpBefore {
|
||
t.Errorf("并发抽帧泄漏临时目录: before=%d after=%d", tmpBefore, tmpAfter)
|
||
}
|
||
}
|
||
|
||
// ⑥ 块引用视频来源:真记忆块取字节 → 临时文件 → 抽帧 → cleanup 删文件。
|
||
func TestResolveVideoInput_DigestCleanup(t *testing.T) {
|
||
p := newProbedPlugin(t)
|
||
video := mkTestVideo(t, 4)
|
||
raw, err := os.ReadFile(video)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
s, ref := sdkWithVideo(t, raw)
|
||
p.sdk = s
|
||
|
||
input, cleanup, err := p.resolveVideoInput(map[string]interface{}{"digest": ref})
|
||
if err != nil {
|
||
t.Fatalf("块引用解析失败: %v", err)
|
||
}
|
||
if _, serr := os.Stat(input); serr != nil {
|
||
t.Fatalf("临时视频文件应存在: %v", serr)
|
||
}
|
||
frames, err := p.extractFrames(input, 2)
|
||
if err != nil || len(frames) == 0 {
|
||
t.Errorf("从 digest 临时文件抽帧失败: err=%v frames=%d", err, len(frames))
|
||
}
|
||
cleanup()
|
||
if _, serr := os.Stat(input); !os.IsNotExist(serr) {
|
||
t.Errorf("cleanup 后临时文件应被删除,stat=%v", serr)
|
||
}
|
||
}
|
||
|
||
// ⑦ 静态判据:所有 ffmpeg/ffprobe 子进程必须用 exec.CommandContext。
|
||
//
|
||
// CommandContext 是「任何情况下都能被 ctx 中断」的契约;换成 exec.Command
|
||
// 就失去取消能力。这条跨实现有效:谁把 CommandContext 改回 Command,谁就引入
|
||
// 「网络流/坏文件上无限挂」的风险。
|
||
func TestFrameCmdsUseCommandContext(t *testing.T) {
|
||
src, err := os.ReadFile("plugin.go")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for i, line := range strings.Split(string(src), "\n") {
|
||
if strings.Contains(line, "exec.Command(") && !strings.Contains(line, "exec.CommandContext(") {
|
||
t.Errorf("plugin.go:%d 用裸 exec.Command 跑子进程 —— 必须 exec.CommandContext(ctx 取消才杀得掉): %s", i+1, strings.TrimSpace(line))
|
||
}
|
||
}
|
||
}
|