mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Reprise des commits 91f7f977 (PR #100 de Trombo38) et 1dfac0cf d'AJEAN. Une vidéo jointe (mp4, webm, mov, mkv, avi) part au modèle en partie input_video, et l'outil see_video charge une vidéo du disque, mais seulement si le moteur actif sait la lire : preset llama.cpp local avec projecteur (MMPROJ) et binaire dont l'aide annonce --video-fps (videoInputSupported, aide relue quand le binaire change, chemin des bibliothèques passé à la seule commande pour ne pas figer binHelp). Strata et les API externes ignoraient la partie en silence : la vidéo reste alors un fichier, annoncé avec la marche à suivre (ffmpeg puis see_image), et see_video n'est pas proposé. llama.cpp a --video-fps depuis la PR #24318 (fin août 2026) ; l'image server-cuda officielle, base du conteneur, installe ffmpeg. La consigne de repli dit quoi faire si ffmpeg manque (installation hors conteneur). Adapté à Loki : - la vidéo de see_video est toujours éphémère (jamais dans l'historique, KEEP_TURN_IMAGES compris) ; même ordre de refus que see_image, sonde /props en dernier, refus sur un poste distant ; - une vidéo jointe est rangée par référence (loki-vid:, LOKI_HOME/chatvid, chiffrée comme le fil), parties recopiées et non mutées comme les images ; retirée d'un fil rouvert (stripImageParts, videoLostMarker) et effacée par le ménage des médias ; - marqueur de perte à la compaction, élision dans l'export JSON, relais par l'API interne des outils (vision_video). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
12 KiB
Go
333 lines
12 KiB
Go
package loki
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// videoEngine simule un moteur qui sait (ou non) lire la vidéo.
|
|
func videoEngine(t *testing.T, ok bool) {
|
|
t.Helper()
|
|
prev := videoInputSupported
|
|
videoInputSupported = func() bool { return ok }
|
|
t.Cleanup(func() { videoInputSupported = prev })
|
|
}
|
|
|
|
// engineSees amorce le cache de la sonde /props : aucun llama-server ne tourne
|
|
// pendant les tests.
|
|
func engineSees(t *testing.T, seen bool) {
|
|
t.Helper()
|
|
visionProbeMu.Lock()
|
|
visionProbeAt, visionProbeSeen = time.Now(), seen
|
|
visionProbeMu.Unlock()
|
|
t.Cleanup(func() { visionProbeMu.Lock(); visionProbeAt = time.Time{}; visionProbeMu.Unlock() })
|
|
}
|
|
|
|
// Une vidéo en base64 part sur disque et revient identique à l'envoi au modèle ;
|
|
// l'historique ne garde qu'une référence de quelques octets.
|
|
func TestVideoRefsRoundTrip(t *testing.T) {
|
|
testHome(t)
|
|
raw := []byte(strings.Repeat("\x00\x00\x00\x18ftypmp42-fake-video-bytes", 5000))
|
|
url := "data:video/mp4;base64," + base64.StdEncoding.EncodeToString(raw)
|
|
msgs := []Message{
|
|
{Role: "user", Content: []map[string]any{
|
|
{"type": "text", "text": "regarde la vidéo"},
|
|
{"type": "input_video", "input_video": map[string]any{"url": url}},
|
|
}},
|
|
{Role: "assistant", Content: "vu"},
|
|
}
|
|
// Relu depuis le JSON (forme []any), comme après un redémarrage.
|
|
var reread []Message
|
|
b, _ := json.Marshal(msgs)
|
|
_ = json.Unmarshal(b, &reread)
|
|
|
|
for _, set := range [][]Message{msgs, reread} {
|
|
orig := contentParts(set[0].Content)[1]
|
|
if !hasInlineVideos(set) || !refVideosInMessages(set) || hasInlineVideos(set) {
|
|
t.Fatal("la vidéo aurait dû être rangée par référence")
|
|
}
|
|
// Recopiée, jamais mutée : un runChat en vol peut lire ces mêmes maps.
|
|
if partVideoURL(orig) != url {
|
|
t.Fatal("refVideosInMessages a muté la partie d'origine")
|
|
}
|
|
js, _ := json.Marshal(set)
|
|
if len(js) > 500 || !strings.Contains(string(js), vidRefScheme) {
|
|
t.Fatalf("historique encore lourd : %d octets", len(js))
|
|
}
|
|
out := expandVideoRefs(set)
|
|
if got := partVideoURL(contentParts(out[0].Content)[1]); got != url {
|
|
t.Fatal("la vidéo renvoyée au modèle diffère de l'originale")
|
|
}
|
|
// L'historique lui-même n'est pas modifié par l'expansion.
|
|
if u := partVideoURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, vidRefScheme) {
|
|
t.Fatal("expandVideoRefs ne doit pas toucher l'historique")
|
|
}
|
|
}
|
|
// Fichier effacé (ménage) : une mention, pas une requête qui échoue.
|
|
_ = os.RemoveAll(chatVidDir())
|
|
vidCacheMu.Lock()
|
|
vidCache, vidCacheOrder, vidCacheBytes = map[string]string{}, nil, 0
|
|
vidCacheMu.Unlock()
|
|
out := expandVideoRefs(msgs)
|
|
if p := contentParts(out[0].Content)[1]; p["type"] != "text" || !strings.Contains(p["text"].(string), "indisponible") {
|
|
t.Fatalf("vidéo disparue : %v", p)
|
|
}
|
|
}
|
|
|
|
// userMessageContent : une vidéo jointe devient une partie input_video (et une
|
|
// image jointe reste une partie image_url), quand la vision est active et que
|
|
// le moteur lit la vidéo.
|
|
func TestUserMessageContentVideo(t *testing.T) {
|
|
videoEngine(t, true)
|
|
visionOn(t)
|
|
engineSees(t, true)
|
|
dir, err := uploadsDir()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "clip-test.mp4"), []byte("fake-mp4-bytes"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "photo.png"), pngPixel, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
in := []attachInfo{
|
|
{Name: "clip-test.mp4", Path: "uploads/clip-test.mp4", Size: 14},
|
|
{Name: "photo.png", Path: "uploads/photo.png", Size: int64(len(pngPixel))},
|
|
}
|
|
parts, ok := userMessageContent(in, "décris ce clip").([]map[string]any)
|
|
if !ok || len(parts) != 3 {
|
|
t.Fatalf("attendu texte + image + vidéo, obtenu %#v", parts)
|
|
}
|
|
if parts[0]["type"] != "text" || parts[1]["type"] != "image_url" || parts[2]["type"] != "input_video" {
|
|
t.Fatalf("ordre des parties : %v %v %v", parts[0]["type"], parts[1]["type"], parts[2]["type"])
|
|
}
|
|
if u := partVideoURL(parts[2]); !strings.HasPrefix(u, "data:video/mp4;base64,") {
|
|
t.Fatalf("data URI vidéo inattendue : %.40q", u)
|
|
}
|
|
// Montrée, elle n'est plus annoncée comme simple fichier.
|
|
if txt := parts[0]["text"].(string); strings.Contains(txt, "clip-test.mp4") || strings.Contains(txt, "ffmpeg") {
|
|
t.Fatalf("vidéo vue annoncée comme fichier : %q", txt)
|
|
}
|
|
}
|
|
|
|
// Moteur sans vidéo (API externe, Strata, llama.cpp ancien) : il ignorait la
|
|
// partie input_video sans erreur et le modèle ne recevait que le texte. La
|
|
// vidéo doit alors être annoncée comme fichier, avec la marche à suivre
|
|
// (ffmpeg puis see_image), et see_video doit le dire au lieu de charger.
|
|
func TestVideoUnsupportedEngine(t *testing.T) {
|
|
videoEngine(t, false)
|
|
visionOn(t)
|
|
engineSees(t, true)
|
|
dir, err := uploadsDir()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
vname := "clip-test.mp4"
|
|
if err := os.WriteFile(filepath.Join(dir, vname), []byte("fake-mp4-bytes"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content := userMessageContent([]attachInfo{{Name: vname, Path: "uploads/" + vname, Size: 14}}, "décris ce clip")
|
|
s, ok := content.(string)
|
|
if !ok {
|
|
t.Fatalf("attendu du texte seul (pas de partie input_video), obtenu %T", content)
|
|
}
|
|
if !strings.Contains(s, "uploads/"+vname) || !strings.Contains(s, "ffmpeg") || !strings.HasSuffix(s, "décris ce clip") {
|
|
t.Fatalf("chemin et consigne ffmpeg attendus : %q", s)
|
|
}
|
|
if msg, part := toolSeeVideo(filepath.Join(dir, vname)); part != nil || !strings.Contains(msg, "ffmpeg") {
|
|
t.Fatalf("see_video doit refuser avec la consigne ffmpeg : %q", msg)
|
|
}
|
|
// Ni outil annoncé, ni consigne quand ce n'est pas une vidéo.
|
|
for _, tl := range EnabledTools(Caps{Agent: true}) {
|
|
if tl.Function.Name == "see_video" {
|
|
t.Fatal("see_video annoncé alors que le moteur ne lit pas la vidéo")
|
|
}
|
|
}
|
|
if s := attachNote([]attachInfo{{Name: "a.txt", Path: "uploads/a.txt", Size: 1}}); strings.Contains(s, "ffmpeg") {
|
|
t.Fatalf("consigne vidéo sur un fichier texte : %q", s)
|
|
}
|
|
}
|
|
|
|
// see_video : annoncé avec see_image quand le moteur lit la vidéo.
|
|
func TestSeeVideoAnnonce(t *testing.T) {
|
|
videoEngine(t, true)
|
|
visionOn(t)
|
|
has := func(caps Caps) bool {
|
|
for _, tl := range EnabledTools(caps) {
|
|
if tl.Function.Name == "see_video" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
if !has(Caps{Agent: true}) {
|
|
t.Fatal("see_video absent alors que le moteur lit la vidéo")
|
|
}
|
|
if has(Caps{}) {
|
|
t.Fatal("see_video hors du mode agent")
|
|
}
|
|
if toolCallLabel("see_video", map[string]any{"file": "a.mp4"}) != "a.mp4" {
|
|
t.Fatal("libellé de see_video vide : l'outil s'exécuterait sur une chaîne vide")
|
|
}
|
|
if dedupableTool("see_video") {
|
|
t.Fatal("see_video dédupliqué : le rappel rendrait « déjà fait » sans la vidéo")
|
|
}
|
|
}
|
|
|
|
// toolSeeVideo : chaque refus nomme son motif ; un mp4 présent est chargé et
|
|
// relayé dans un message utilisateur qui nomme le fichier.
|
|
func TestToolSeeVideo(t *testing.T) {
|
|
videoEngine(t, true)
|
|
home := visionOn(t)
|
|
engineSees(t, true)
|
|
if err := os.MkdirAll(filepath.Join(home, "dossier.mp4"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gros := filepath.Join(home, "gros.mp4")
|
|
f, _ := os.Create(gros)
|
|
_ = f.Truncate(maxVideoBytes + 1)
|
|
f.Close()
|
|
_ = os.WriteFile(filepath.Join(home, "vide.webm"), nil, 0o600)
|
|
cas := []struct{ path, attendu string }{
|
|
{"", "chemin de fichier manquant"},
|
|
{filepath.Join(home, "notes.txt"), "format non reconnu comme vidéo"},
|
|
{filepath.Join(home, "nulle-part.mp4"), "introuvable"},
|
|
{filepath.Join(home, "dossier.mp4"), "dossier"},
|
|
{gros, "trop lourde"},
|
|
{filepath.Join(home, "vide.webm"), "fichier vide"},
|
|
}
|
|
for _, c := range cas {
|
|
msg, part := toolSeeVideo(c.path)
|
|
if part != nil || !strings.HasPrefix(msg, "[erreur]") || !strings.Contains(msg, c.attendu) {
|
|
t.Errorf("%q : %q, attendu une erreur contenant %q", c.path, msg, c.attendu)
|
|
}
|
|
}
|
|
p := filepath.Join(home, "clip.MOV")
|
|
if err := os.WriteFile(p, []byte("fake-mov"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
msg, part := toolSeeVideo(p)
|
|
if part == nil || msg != "[ok] vidéo chargée : clip.MOV" {
|
|
t.Fatalf("attendu un [ok] : %q / %v", msg, part)
|
|
}
|
|
if u := partVideoURL(part); u != "data:video/quicktime;base64,"+base64.StdEncoding.EncodeToString([]byte("fake-mov")) {
|
|
t.Fatalf("partie inattendue : %v", part)
|
|
}
|
|
// Le moteur en service ne voit pas : on le dit au lieu d'envoyer 200 Mio.
|
|
engineSees(t, false)
|
|
if msg, part := toolSeeVideo(p); part != nil || !strings.Contains(msg, "ne traite pas les images") {
|
|
t.Fatalf("moteur aveugle : %q", msg)
|
|
}
|
|
// Relais : passé par l'API interne comme see_image (toolStepExtras).
|
|
_, _, relay := toolStepExtras("see_video", "clip.MOV", toolExecResult{Result: "[ok]", VisionVideo: videoPart([]byte("x"), "video/mp4")})
|
|
if relay == nil {
|
|
t.Fatal("aucun relais pour see_video")
|
|
}
|
|
rp := contentParts(relay.Content)
|
|
if relay.Role != "user" || rp[0]["text"] != "Vidéo demandée (clip.MOV) :" || rp[1]["type"] != "input_video" {
|
|
t.Fatalf("relais inattendu : %#v", relay)
|
|
}
|
|
}
|
|
|
|
// Sans projecteur ou sur un poste distant : refus explicite, avant toute
|
|
// question au moteur.
|
|
func TestSeeVideoSansVision(t *testing.T) {
|
|
videoEngine(t, true)
|
|
testHome(t)
|
|
if msg, part := toolSeeVideo("a.mp4"); part != nil || !strings.Contains(msg, "MMPROJ") {
|
|
t.Fatalf("sans vision : %q", msg)
|
|
}
|
|
}
|
|
|
|
// videoInputSupported : jamais pour une API externe, Strata, un preset sans
|
|
// projecteur ou sans moteur ; sinon, l'aide du binaire fait foi.
|
|
func TestVideoInputSupportedPresets(t *testing.T) {
|
|
testHome(t)
|
|
cas := []map[string]string{
|
|
{"BIN": "llama-server", "MODEL": "m.gguf"}, // pas de projecteur
|
|
{"MMPROJ": "mm.gguf", "MODEL": "m.gguf"}, // pas de moteur
|
|
{"MMPROJ": "mm.gguf", "BIN": "introuvable/llama-server"}, // moteur absent
|
|
{"MMPROJ": "mm.gguf", extKeyFlag: "1", extKeyVision: "1"}, // API externe
|
|
{"MMPROJ": "mm.gguf", "ENGINE": engineStrata, "BIN": "/bin/sh", "MODEL": "m"}, // Strata
|
|
}
|
|
for _, cfg := range cas {
|
|
if err := WriteConfig(cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if videoInputSupported() {
|
|
t.Errorf("%v : vidéo annoncée", cfg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// L'aide du binaire décide, relue quand le binaire change (mise à jour en place).
|
|
func TestBinHelpHasVideo(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("faux moteur en sh")
|
|
}
|
|
dir := t.TempDir()
|
|
bin := filepath.Join(dir, "llama-server")
|
|
write := func(help string, mt time.Time) {
|
|
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho '"+help+"'\n"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = os.Chtimes(bin, mt, mt)
|
|
}
|
|
write("--mmproj FILE", time.Now().Add(-time.Hour))
|
|
if binHelpHasVideo(bin) {
|
|
t.Fatal("moteur sans --video-fps annoncé comme lisant la vidéo")
|
|
}
|
|
write("--video-fps N target fps", time.Now())
|
|
if !binHelpHasVideo(bin) {
|
|
t.Fatal("moteur mis à jour : --video-fps non relu")
|
|
}
|
|
}
|
|
|
|
// Compaction, fil rechargé, export : la vidéo n'est jamais rejouée ni
|
|
// recopiée, mais sa perte reste visible.
|
|
func TestVideoPerdueVisible(t *testing.T) {
|
|
part := videoPart([]byte(strings.Repeat("v", 3000)), "video/mp4")
|
|
m := Message{Role: "user", Content: []map[string]any{{"type": "text", "text": "regarde"}, part}}
|
|
if got := msgText(m); got != "regarde"+videoLostMarker {
|
|
t.Fatalf("msgText : %q", got)
|
|
}
|
|
var reread []Message
|
|
b, _ := json.Marshal([]Message{m})
|
|
_ = json.Unmarshal(b, &reread)
|
|
out := stripImageParts(reread)
|
|
if s, ok := out[0].Content.(string); !ok || s != "regarde"+videoLostMarker {
|
|
t.Fatalf("fil rechargé : %#v", out[0].Content)
|
|
}
|
|
exp := stripInlineImages([]Message{m})
|
|
js, _ := json.Marshal(exp)
|
|
if len(js) > 400 || !strings.Contains(string(js), "élidé") {
|
|
t.Fatalf("export : %s", js)
|
|
}
|
|
if partVideoURL(contentParts(m.Content)[1]) == "" || !strings.HasPrefix(partVideoURL(part), "data:video/mp4;base64,") {
|
|
t.Fatal("l'export a touché le message vivant")
|
|
}
|
|
}
|
|
|
|
// Le ménage des médias vide aussi les vidéos jointes trop vieilles.
|
|
func TestPruneChatVideos(t *testing.T) {
|
|
testHome(t)
|
|
ref, err := storeChatVideo([]byte("vieille"), "video/webm")
|
|
if err != nil || !strings.HasSuffix(ref, ".webm") {
|
|
t.Fatalf("rangement : %q %v", ref, err)
|
|
}
|
|
p := filepath.Join(chatVidDir(), strings.TrimPrefix(ref, vidRefScheme))
|
|
old := time.Now().Add(-2 * chatImgKeep)
|
|
_ = os.Chtimes(p, old, old)
|
|
pruneChatImages()
|
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
|
t.Fatal("vieille vidéo gardée")
|
|
}
|
|
}
|