From ac74ecde0baf6467144d8ae9821f474b1d649bf3 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 16 Oct 2026 15:01:40 +0200 Subject: [PATCH] =?UTF-8?q?Fa=C3=A7ade=20:=20see=5Fvideo=20ex=C3=A9cut?= =?UTF-8?q?=C3=A9=20par=20la=20fa=C3=A7ade,=20vid=C3=A9os=20jointes=20rang?= =?UTF-8?q?=C3=A9es=20relues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comme see_image (lot 45), see_video est tenu par la façade : imagetools::see_video porte toolSeeVideo (mêmes refus, même ordre, sonde du moteur en dernier) et video_input_supported la capacité du moteur (aide du binaire, --video-fps, relue quand il change). La vidéo relayée est toujours éphémère, KEEP_TURN_IMAGES compris (turn.rs) ; vision_video relu de /tool/exec quand le Go exécute l'outil. Vidéos jointes rangées (loki-vid:) : remises en data-URL avant l'envoi avec les images (expand_image_refs, cache des médias élargi à chatvid). Libellé et non-dédup de see_video, videoLostMarker à la compaction et au rechargement d'un fil, élision dans l'export. Parité : journal tiré du Go (parity_video_tool_test.go → tests/parity_images/video.json : refus, moteur sans vidéo, moteur aveugle, vidéos relayées à l'octet), rejoué par tests/videotools.rs ; journal multiplateforme (ni faux Playwright ni sh). Co-Authored-By: Claude Opus 5.5 --- internal/loki/parity_video_tool_test.go | 197 ++++++++++++++++ rust/loki-rs/src/chat/compact.rs | 4 + rust/loki-rs/src/chat/host.rs | 117 ++++++++-- rust/loki-rs/src/chat/nudges.rs | 9 +- rust/loki-rs/src/chat/persist.rs | 29 ++- rust/loki-rs/src/chat/stream.rs | 2 +- rust/loki-rs/src/chat/turn.rs | 12 +- rust/loki-rs/src/chat/wire.rs | 103 ++++++-- rust/loki-rs/src/chatx.rs | 14 ++ rust/loki-rs/src/imagetools.rs | 163 +++++++++++++ rust/loki-rs/tests/parity_images/video.json | 245 ++++++++++++++++++++ rust/loki-rs/tests/videotools.rs | 231 ++++++++++++++++++ 12 files changed, 1084 insertions(+), 42 deletions(-) create mode 100644 internal/loki/parity_video_tool_test.go create mode 100644 rust/loki-rs/tests/parity_images/video.json create mode 100644 rust/loki-rs/tests/videotools.rs diff --git a/internal/loki/parity_video_tool_test.go b/internal/loki/parity_video_tool_test.go new file mode 100644 index 0000000..20ff947 --- /dev/null +++ b/internal/loki/parity_video_tool_test.go @@ -0,0 +1,197 @@ +package loki + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// parity_video_tool_test.go — journal de référence de l'outil see_video +// (repris d'AJEAN, tenu par la façade comme see_image, lot 45) : refus dans +// le même ordre que see_image, moteur qui ne lit pas la vidéo (consigne +// ffmpeg), sonde « le moteur voit-il » (faux /props) et vidéo relayée au +// modèle, à l'octet (une vidéo n'est jamais réencodée). +// +// La capacité vidéo du moteur (videoInputSupported : aide du binaire) est +// fixée par chaque étape : la façade la calcule de son côté par le même +// chemin (imagetools::video_input_supported) et la passe à see_video. Rangé +// dans rust/loki-rs/tests/parity_images/video.json ; tests/videotools.rs le +// rejoue contre le port. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutilVideo + +const parityVideoFile = "../../rust/loki-rs/tests/parity_images/video.json" + +type parityVideoStep struct { + Name string `json:"name"` + Vision bool `json:"vision,omitempty"` + Sees bool `json:"sees,omitempty"` // le faux /props annonce la vision + Video bool `json:"video,omitempty"` // le moteur lit la vidéo (--video-fps) + Args map[string]any `json:"args"` + Label string `json:"label"` + Result string `json:"result"` + Part map[string]any `json:"part,omitempty"` // vidéo relayée + Probes int `json:"probes"` +} + +func TestPariteOutilVideo(t *testing.T) { + testHome(t) + files := filepath.Join(t.TempDir(), "fichiers") + raw := map[string][]byte{ + "clip.mp4": []byte("\x00\x00\x00\x18ftypmp42 fausse vidéo"), + "CLIP.MOV": []byte("\x00\x00\x00\x14ftypqt mov"), + "anim.webm": []byte("\x1aE\xdf\xa3 webm"), + "film.mkv": []byte("\x1aE\xdf\xa3 matroska"), + "vieux.avi": []byte("RIFF\x10\x00\x00\x00AVI LIST"), + "sous/court.mp4": []byte("ftyp sous-dossier"), + "texte.txt": []byte("pas une vidéo"), + "vide.mp4": {}, + } + for name, b := range raw { + p := filepath.Join(files, name) + _ = os.MkdirAll(filepath.Dir(p), 0o755) + if err := os.WriteFile(p, b, 0o644); err != nil { + t.Fatal(err) + } + } + _ = os.MkdirAll(filepath.Join(files, "dossier.mp4"), 0o755) + lourd, _ := os.Create(filepath.Join(files, "lourd.mp4")) + _ = lourd.Truncate(maxVideoBytes + 1) + lourd.Close() + setTaskWorkspace(files) + t.Cleanup(func() { setTaskWorkspace("") }) + + // Faux moteur : /props, authentifié. + var mu sync.Mutex + sees, probes := false, 0 + eng := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + if r.URL.Path != "/props" || r.Header.Get("Authorization") != "Bearer cle-moteur" { + w.WriteHeader(401) + return + } + probes++ + _ = json.NewEncoder(w).Encode(map[string]any{"modalities": map[string]any{"vision": sees}}) + })) + defer eng.Close() + _ = SetConfigKey("PORT", strings.TrimPrefix(eng.URL, "http://127.0.0.1:")) + _ = putStr(bkState, "api_key", "cle-moteur") + prevVideo := videoInputSupported + t.Cleanup(func() { videoInputSupported = prevVideo }) + + var steps []parityVideoStep + run := func(st parityVideoStep) { + if st.Vision { + _ = SetConfigKey("MMPROJ", "vision.gguf") + } else { + _ = SetConfigKey("MMPROJ", "") + } + video := st.Video + videoInputSupported = func() bool { return video } + mu.Lock() + sees, probes = st.Sees, 0 + mu.Unlock() + visionProbeMu.Lock() + visionProbeAt = time.Time{} + visionProbeMu.Unlock() + args := map[string]any{} + b, _ := json.Marshal(st.Args) + _ = json.Unmarshal([]byte(strings.ReplaceAll(string(b), "{{FICHIERS}}", filepath.ToSlash(files))), &args) + label := toolCallLabel("see_video", args) + st.Label = strings.ReplaceAll(label, filepath.ToSlash(files), "{{FICHIERS}}") + res := execTool(t.Context(), "see_video", args, label, Caps{Agent: true}) + _, _, relay := toolStepExtras("see_video", label, res) + st.Result = strings.ReplaceAll(res.Result, filepath.ToSlash(files), "{{FICHIERS}}") + if relay != nil { + for _, p := range contentParts(relay.Content) { + switch p["type"] { + case "text": + st.Result += "\n[relais] " + strings.ReplaceAll(fmt.Sprint(p["text"]), filepath.ToSlash(files), "{{FICHIERS}}") + case "input_video": + st.Part = parityVideoFacts(t, p, raw) + } + } + } + mu.Lock() + st.Probes = probes + mu.Unlock() + if st.Args == nil { + st.Args = map[string]any{} + } + steps = append(steps, st) + } + a := func(f string) map[string]any { return map[string]any{"file": f} } + + run(parityVideoStep{Name: "sans vision", Video: true, Args: a("clip.mp4")}) + run(parityVideoStep{Name: "chemin manquant", Vision: true, Video: true, Args: a("")}) + run(parityVideoStep{Name: "sans argument", Vision: true, Video: true}) + run(parityVideoStep{Name: "moteur sans vidéo", Vision: true, Sees: true, Args: a("clip.mp4")}) + run(parityVideoStep{Name: "pas une vidéo", Vision: true, Video: true, Args: a("texte.txt")}) + run(parityVideoStep{Name: "image", Vision: true, Video: true, Args: a("photo.png")}) + run(parityVideoStep{Name: "introuvable", Vision: true, Video: true, Args: a("absente.mp4")}) + run(parityVideoStep{Name: "dossier", Vision: true, Video: true, Args: a("dossier.mp4")}) + run(parityVideoStep{Name: "trop lourde", Vision: true, Video: true, Args: a("lourd.mp4")}) + run(parityVideoStep{Name: "le moteur ne voit pas", Vision: true, Video: true, Args: a("clip.mp4")}) + run(parityVideoStep{Name: "vide", Vision: true, Sees: true, Video: true, Args: a("vide.mp4")}) + for _, f := range []string{"clip.mp4", "CLIP.MOV", "anim.webm", "film.mkv", "vieux.avi", "sous/court.mp4"} { + run(parityVideoStep{Name: "voir " + f, Vision: true, Sees: true, Video: true, Args: a(f)}) + } + run(parityVideoStep{Name: "chemin absolu", Vision: true, Sees: true, Video: true, Args: a("{{FICHIERS}}/clip.mp4")}) + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"files": raw, "steps": steps}); err != nil { + t.Fatal(err) + } + got := buf.Bytes() + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityVideoFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityVideoFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityVideoFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("see_video ne produit plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityVideoFile) + } +} + +// parityVideoFacts : le type et le fichier d'origine de la vidéo relayée (à +// l'octet : une vidéo part telle quelle). +func parityVideoFacts(t *testing.T, part map[string]any, raw map[string][]byte) map[string]any { + u := partVideoURL(part) + i := strings.Index(u, ";base64,") + if i < 0 { + t.Fatalf("partie vidéo sans base64 : %.60q", u) + } + b, err := base64.StdEncoding.DecodeString(u[i+8:]) + if err != nil { + t.Fatal(err) + } + out := map[string]any{"mime": strings.TrimPrefix(u[:i], "data:")} + for name, r := range raw { + if bytes.Equal(r, b) { + out["same_as"] = name + } + } + return out +} diff --git a/rust/loki-rs/src/chat/compact.rs b/rust/loki-rs/src/chat/compact.rs index 8aea243..5fd6fe7 100644 --- a/rust/loki-rs/src/chat/compact.rs +++ b/rust/loki-rs/src/chat/compact.rs @@ -58,6 +58,9 @@ pub const COMPACT_SUMMARY_PREFIX: &str = "[CONTEXT COMPACTED]"; /// Remplace la partie `image_url` d'un message multimodal dans [`msg_text`]. pub const IMAGE_LOST_MARKER: &str = " [image — not kept in the conversation past this point; look at it again (see_image on the file, or a new screenshot) if you still need to see it]"; +/// `videoLostMarker` : remplace la partie `input_video` (chat_video.go). +pub const VIDEO_LOST_MARKER: &str = " [video — not kept in the conversation past this point; call see_video on the file again if you still need to watch it]"; + /// La réponse assistant synthétique qui suit le résumé. pub const COMPACT_ACK_MSG: &str = "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."; @@ -265,6 +268,7 @@ pub fn msg_text(m: &Message) -> String { } } Some("image_url") => b.push_str(IMAGE_LOST_MARKER), + Some("input_video") => b.push_str(VIDEO_LOST_MARKER), _ => {} } } diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 51ce101..3d83c23 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -14,8 +14,9 @@ //! - compaction par [`Compactor`] : résumé demandé au moteur //! ([`EngineSummarizer`]), blocs archivés pour recall par le Go //! ([`StoreRecall`]) ; -//! - images jointes (`loki-img:…`) lues sur disque et ouvertes par le Go -//! (déchiffrées), gardées en cache comme `chatImageDataURL` ; +//! - images et vidéos jointes (`loki-img:…`, `loki-vid:…`) lues sur disque +//! et ouvertes par le Go (déchiffrées), gardées en cache comme +//! `chatImageDataURL` / `chatVideoDataURL` ; //! - slots du moteur (lot 31) : avant chaque requête au moteur local, le Go //! rend le slot où l'envoyer (SIDE_SLOT) et recharge au besoin l'état //! gardé de la discussion (SLOT_PERSIST) ; la requête est comptée en vol, @@ -46,8 +47,8 @@ use super::stream::{CacheHintFn, PreflightFn, RetryPatterns}; use super::turn::{CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnHost}; use super::types::{AskEvent, DiffLine, Message, Tool}; use super::wire::{ - ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME, - build_chat_payload, content_parts, part_image_url, wire_messages, + ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME, VID_REF_SCHEME, + build_chat_payload, content_parts, part_image_url, part_video_url, wire_messages, }; use crate::store::{BK_STATE, Store}; @@ -97,10 +98,32 @@ fn image_mime(name: &str) -> &'static str { /// Nom d'une image rangée, si la référence est sûre (ni chemin ni `..`). fn image_name(r: &str) -> Option<&str> { - let name = r.strip_prefix(IMG_REF_SCHEME)?; + safe_media_name(r.strip_prefix(IMG_REF_SCHEME)?) +} + +fn safe_media_name(name: &str) -> Option<&str> { (!name.is_empty() && !name.contains(['/', '\\']) && !name.contains("..")).then_some(name) } +/// Média rangé : (clé du cache, fichier relatif à `LOKI_HOME`, type MIME). +/// Une image a pour clé son nom (dossier `chatimg`), une vidéo jointe +/// (`loki-vid:`, chat_video.go) sa référence entière (dossier `chatvid`). +fn media_ref(r: &str) -> Option<(String, PathBuf, &'static str)> { + if let Some(name) = image_name(r) { + return Some(( + name.to_string(), + Path::new("chatimg").join(name), + image_mime(name), + )); + } + let name = safe_media_name(r.strip_prefix(VID_REF_SCHEME)?)?; + let mime = match crate::imagetools::video_mime(name) { + "" => "video/mp4", + m => m, + }; + Some((r.to_string(), Path::new("chatvid").join(name), mime)) +} + #[derive(Default)] struct ImgCacheInner { map: HashMap, @@ -129,9 +152,10 @@ impl ImageCache { } } + /// Data-URL d'une image (`loki-img:`) ou d'une vidéo (`loki-vid:`) rangée. pub fn get(&self, r: &str) -> Option { - let name = image_name(r)?; - self.inner.lock().ok()?.map.get(name).cloned() + let (key, _, _) = media_ref(r)?; + self.inner.lock().ok()?.map.get(&key).cloned() } fn put(&self, name: &str, url: String) { @@ -151,27 +175,30 @@ impl ImageCache { } } - /// Lit et ouvre (par le Go) les images référencées pas encore en cache. - /// Une image illisible reste absente : la requête dira « indisponible ». + /// Lit et ouvre (par le Go) les images et vidéos référencées pas encore + /// en cache. Un média illisible reste absent : la requête dira + /// « indisponible ». pub async fn prefetch(&self, store: &Store, msgs: &[Message]) { + let home = self.home(); for m in msgs { for p in content_parts(m.content.as_ref()) { - let Some(name) = image_name(part_image_url(p)) else { + let u = match part_image_url(p) { + "" => part_video_url(p), + u => u, + }; + let Some((key, rel, mime)) = media_ref(u) else { continue; }; - if self.inner.lock().is_ok_and(|g| g.map.contains_key(name)) { + if self.inner.lock().is_ok_and(|g| g.map.contains_key(&key)) { continue; } - let Ok(raw) = tokio::fs::read(self.dir.join(name)).await else { + let Ok(raw) = tokio::fs::read(home.join(rel)).await else { continue; }; let Ok(b) = store.open(raw).await else { continue; }; - self.put( - name, - format!("data:{};base64,{}", image_mime(name), b64(&b)), - ); + self.put(&key, format!("data:{mime};base64,{}", b64(&b))); } } } @@ -586,6 +613,11 @@ pub fn tool_outcome(v: &Value, label: &str) -> ToolOutcome { { relay = Some(see_image_message(label, img.clone())); } + if relay.is_none() + && let Some(vid) = v.get("vision_video").filter(|i| i.is_object()) + { + relay = Some(see_video_message(label, vid.clone())); + } ToolOutcome { result: s("result"), diff: v @@ -617,6 +649,20 @@ pub fn see_image_message(label: &str, img: Value) -> Message { } } +/// `seeVideoMessage` : la vidéo relayée au modèle. +pub fn see_video_message(label: &str, vid: Value) -> Message { + let text = if label.is_empty() { + "Vidéo :".to_string() + } else { + format!("Vidéo demandée ({label}) :") + }; + Message { + role: "user".into(), + content: Some(json!([{"type": "text", "text": text}, vid])), + ..Default::default() + } +} + impl GoTurnHost { /// Les outils exécutés par la façade, sauf `subagent` ; le reste par le /// Go (`/tool/exec`). Aussi celui des tours d'un sous-agent. @@ -751,6 +797,45 @@ impl GoTurnHost { }; } } + // see_video (chat_video_tool.go) : comme see_image, dans le dossier de + // la discussion ; la vidéo relayée au modèle, toujours éphémère. + if name == "see_video" { + let cfg = self.shared.store.config().await; + let vision = crate::system::vision_enabled(&cfg); + let st = crate::tools::GoToolState { + store: self.shared.store.clone(), + caps: self.caps.clone(), + }; + match crate::tools::ToolState::context(&st).await { + Ok(ctx) => { + let home = self.shared.images.home(); + let vcfg = cfg.clone(); + let video_ok = tokio::task::spawn_blocking(move || { + crate::imagetools::video_input_supported(&vcfg, &home) + }) + .await + .unwrap_or(false); + let sees = self.shared.vision.sees(&self.shared.store, &cfg); + let (result, vid) = crate::imagetools::see_video( + label, + &ctx.cwd, + &ctx.target, + vision, + video_ok, + sees, + ) + .await; + return ToolOutcome { + result, + relay: vid.map(|v| see_video_message(label, v)), + ..Default::default() + }; + } + Err(e) => { + eprintln!("[outils] contexte indisponible, see_video par le Go : {e}") + } + } + } // Navigateur piloté (lot 44) : la session est ici ; aperçu de la page // après une action qui la change (jamais après une erreur), capture // relayée au modèle. diff --git a/rust/loki-rs/src/chat/nudges.rs b/rust/loki-rs/src/chat/nudges.rs index 9f59135..4a91d67 100644 --- a/rust/loki-rs/src/chat/nudges.rs +++ b/rust/loki-rs/src/chat/nudges.rs @@ -141,10 +141,13 @@ pub fn steer_system(msgs: &[Message], hint: &str) -> Vec { } /// `dedupableTool` : un appel identique se court-circuite, sauf bash et ses -/// jobs (effets de bord), see_image et le navigateur (état vivant, image à -/// part). +/// jobs (effets de bord), see_image, see_video et le navigateur (état +/// vivant, image ou vidéo à part). pub fn dedupable_tool(name: &str) -> bool { - !matches!(name, "bash" | "bash_bg" | "bash_tail" | "see_image") && !name.starts_with("browser_") + !matches!( + name, + "bash" | "bash_bg" | "bash_tail" | "see_image" | "see_video" + ) && !name.starts_with("browser_") } /// `repeatedCallResult` : réponse à un appel RIGOUREUSEMENT identique déjà diff --git a/rust/loki-rs/src/chat/persist.rs b/rust/loki-rs/src/chat/persist.rs index f7eebed..6f3d32b 100644 --- a/rust/loki-rs/src/chat/persist.rs +++ b/rust/loki-rs/src/chat/persist.rs @@ -49,7 +49,7 @@ use serde::{Deserialize, Serialize, Serializer}; use serde_json::Value; use tokio::sync::watch; -use super::compact::{IMAGE_LOST_MARKER, is_loki_injected, without_ctx_update}; +use super::compact::{IMAGE_LOST_MARKER, VIDEO_LOST_MARKER, is_loki_injected, without_ctx_update}; use super::journal::compact_log; use super::types::{LogEvent, Message}; use crate::store::{CryptError, Store}; @@ -269,24 +269,39 @@ fn decode_list(v: Value) -> Vec { /// `stripImageParts` : un message multimodal persisté (base64 d'une capture /// d'avant les captures éphémères) est aplati en son texte suivi de /// [`IMAGE_LOST_MARKER`] — rejoué à chaque tour, il dépassait le contexte. +/// Une vidéo jointe (`input_video`) a le même sort, marquée par +/// [`VIDEO_LOST_MARKER`]. pub fn strip_image_parts(msgs: &mut [Message]) { for m in msgs.iter_mut() { let Some(Value::Array(parts)) = &m.content else { continue; }; let mut texts: Vec<&str> = Vec::new(); - let mut dropped = false; + let (mut dropped, mut dropped_vid) = (false, false); for p in parts.iter().filter_map(Value::as_object) { - if p.get("type").and_then(Value::as_str) == Some("image_url") { - dropped = true; - continue; + match p.get("type").and_then(Value::as_str) { + Some("image_url") => { + dropped = true; + continue; + } + Some("input_video") => { + dropped_vid = true; + continue; + } + _ => {} } if let Some(Value::String(t)) = p.get("text") { texts.push(t); } } - if dropped { - let text = texts.join("\n") + IMAGE_LOST_MARKER; + if dropped || dropped_vid { + let mut text = texts.join("\n"); + if dropped { + text += IMAGE_LOST_MARKER; + } + if dropped_vid { + text += VIDEO_LOST_MARKER; + } m.content = Some(Value::String(text)); } } diff --git a/rust/loki-rs/src/chat/stream.rs b/rust/loki-rs/src/chat/stream.rs index 259c3f1..8595abd 100644 --- a/rust/loki-rs/src/chat/stream.rs +++ b/rust/loki-rs/src/chat/stream.rs @@ -621,7 +621,7 @@ pub fn tool_call_label(name: &str, args: &Map) -> String { match name { "mem_search" | "web_search" | "web_images" | "recall_search" => arg_str(args, "query"), "mem_read" | "mem_add" | "mem_edit" | "edit" | "write" | "read" | "git_diff" - | "see_image" => arg_str(args, "file"), + | "see_image" | "see_video" => arg_str(args, "file"), "recall" | "bash_tail" => arg_str(args, "id"), "tracker" => (go_str(args.get("action")) + " " + &go_str(args.get("name"))) .trim() diff --git a/rust/loki-rs/src/chat/turn.rs b/rust/loki-rs/src/chat/turn.rs index 5a9b6e2..607fcad 100644 --- a/rust/loki-rs/src/chat/turn.rs +++ b/rust/loki-rs/src/chat/turn.rs @@ -384,6 +384,13 @@ pub fn side_slot_pool_error(msg: &str) -> bool { && !REQ_EXCEEDS.is_match(msg) } +/// Le relais porte-t-il une vidéo (see_video, partie `input_video`) ? +fn is_video_relay(m: &Message) -> bool { + super::wire::content_parts(m.content.as_ref()) + .iter() + .any(|p| p.get("type").and_then(Value::as_str) == Some("input_video")) +} + fn text_msg(role: &str, text: impl Into) -> Message { Message { role: role.into(), @@ -1071,8 +1078,11 @@ async fn run_turn_loop( push_result(&mut messages, &mut extra, cap_tool_result(&ex.result)); if let Some(img) = ex.relay { match kt.as_mut() { - // Éphémère : jamais dans l'historique. + // Éphémère : jamais dans l'historique. Une vidéo + // (see_video) l'est toujours, KEEP_TURN_IMAGES compris : + // le rangement ne sait garder que des images. None => messages.push(img), + Some(_) if is_video_relay(&img) => messages.push(img), // KEEP_TURN_IMAGES : gardée par référence, à l'essai // jusqu'à la mesure de son coût. Some(k) => { diff --git a/rust/loki-rs/src/chat/wire.rs b/rust/loki-rs/src/chat/wire.rs index 17474c1..2adf750 100644 --- a/rust/loki-rs/src/chat/wire.rs +++ b/rust/loki-rs/src/chat/wire.rs @@ -60,6 +60,10 @@ pub const TOOLS_OFF_HINT: &str = "Do not call any more tools. Answer now, direct /// `imgRefScheme` : adresse d'une image rangée sur disque (chat_images.go). pub const IMG_REF_SCHEME: &str = "loki-img:"; +/// `vidRefScheme` : adresse d'une vidéo jointe rangée sur disque +/// (chat_video.go). +pub const VID_REF_SCHEME: &str = "loki-vid:"; + /// Préfixes des messages système propres au projet ou à la conversation /// (`isProjectSystem`). pub const PROJECT_CONTEXT_PREFIX: &str = "Project context"; @@ -308,35 +312,61 @@ pub fn part_image_url(p: &Map) -> &str { .unwrap_or("") } -/// `expandImageRefs` : COPIE des messages prête pour le modèle — chaque -/// référence `loki-img:` redevient sa data-URL (`image_data_url`, qui tient -/// lieu de `chatImageDataURL` : disque, cache, déchiffrement). Les messages -/// sans référence sont repris tels quels ; aucune référence : pas de copie. -/// Une image introuvable devient une mention textuelle. +/// `partVideoURL` : l'adresse d'une partie `input_video` (`url`, sinon +/// `data`), "" pour toute autre partie (chat_video.go). +pub fn part_video_url(p: &Map) -> &str { + if p.get("type").and_then(Value::as_str) != Some("input_video") { + return ""; + } + let Some(iv) = p.get("input_video").and_then(Value::as_object) else { + return ""; + }; + match iv.get("url").and_then(Value::as_str).unwrap_or("") { + "" => iv.get("data").and_then(Value::as_str).unwrap_or(""), + u => u, + } +} + +/// `expandVideoRefs(expandImageRefs(…))` : COPIE des messages prête pour le +/// modèle — chaque référence `loki-img:` ou `loki-vid:` redevient sa +/// data-URL (`image_data_url`, qui tient lieu de `chatImageDataURL` et +/// `chatVideoDataURL` : disque, cache, déchiffrement). Les messages sans +/// référence sont repris tels quels ; aucune référence : pas de copie. Un +/// média introuvable devient une mention textuelle. pub fn expand_image_refs<'a>( msgs: &'a [Message], image_data_url: &dyn Fn(&str) -> Option, ) -> Cow<'a, [Message]> { + let is_ref = |p: &Map| { + part_image_url(p).starts_with(IMG_REF_SCHEME) + || part_video_url(p).starts_with(VID_REF_SCHEME) + }; let mut out: Option> = None; for (i, m) in msgs.iter().enumerate() { let parts = content_parts(m.content.as_ref()); - if !parts - .iter() - .any(|p| part_image_url(p).starts_with(IMG_REF_SCHEME)) - { + if !parts.iter().any(|p| is_ref(p)) { continue; } let mut np: Vec = Vec::with_capacity(parts.len()); for p in parts { - let u = part_image_url(p); - if !u.starts_with(IMG_REF_SCHEME) { + if !is_ref(p) { np.push(Value::Object(p.clone())); continue; } - match image_data_url(u) { - Some(d) => np.push(json!({"type": "image_url", "image_url": {"url": d}})), + let u = part_image_url(p); + if u.starts_with(IMG_REF_SCHEME) { + match image_data_url(u) { + Some(d) => np.push(json!({"type": "image_url", "image_url": {"url": d}})), + None => np.push( + json!({"type": "text", "text": "[image indisponible : fichier supprimé]"}), + ), + } + continue; + } + match image_data_url(part_video_url(p)) { + Some(d) => np.push(json!({"type": "input_video", "input_video": {"url": d}})), None => np.push( - json!({"type": "text", "text": "[image indisponible : fichier supprimé]"}), + json!({"type": "text", "text": "[vidéo indisponible : fichier supprimé]"}), ), } } @@ -1807,6 +1837,51 @@ mod tests { )); } + // --- chat_video_test.go (partie envoi) ---------------------------------- + + #[test] + fn video_refs_round_trip() { + let url = "data:video/mp4;base64,AAAAGGZ0eXA="; + let reference = format!("{VID_REF_SCHEME}0123abcd.mp4"); + let img_ref = format!("{IMG_REF_SCHEME}ab.png"); + let set = vec![Message { + role: "user".into(), + content: Some(json!([ + {"type": "text", "text": "regarde"}, + {"type": "image_url", "image_url": {"url": img_ref}}, + {"type": "input_video", "input_video": {"url": reference}}, + {"type": "input_video", "input_video": {"data": "data:video/webm;base64,AA=="}} + ])), + ..Default::default() + }]; + let resolver = |r: &str| { + if r == reference { + Some(url.to_string()) + } else if r == img_ref { + Some("data:image/png;base64,AA==".to_string()) + } else { + None + } + }; + let out = expand_image_refs(&set, &resolver); + let parts = content_parts(out[0].content.as_ref()); + assert_eq!(part_image_url(parts[1]), "data:image/png;base64,AA=="); + assert_eq!(part_video_url(parts[2]), url); + assert_eq!(parts[2]["type"], "input_video"); + // Vidéo déjà en ligne (champ data) : reprise telle quelle. + assert_eq!(part_video_url(parts[3]), "data:video/webm;base64,AA=="); + let first = content_parts(set[0].content.as_ref()); + assert!( + part_video_url(first[2]).starts_with(VID_REF_SCHEME), + "l'historique n'est pas touché" + ); + let out = expand_image_refs(&set, &|_: &str| None); + assert_eq!( + content_parts(out[0].content.as_ref())[2]["text"], + json!("[vidéo indisponible : fichier supprimé]") + ); + } + // --- chat_compact_cont_test.go ------------------------------------------ #[test] diff --git a/rust/loki-rs/src/chatx.rs b/rust/loki-rs/src/chatx.rs index bf93572..72bc6ce 100644 --- a/rust/loki-rs/src/chatx.rs +++ b/rust/loki-rs/src/chatx.rs @@ -194,6 +194,20 @@ pub fn strip_inline_images(msgs: Vec) -> Vec { let e = elide_data_uri(url); iu.insert("url".into(), Value::String(e)); } + // Vidéo jointe (input_video, chat_video.go) : même + // élision, sur ses deux champs. + if let Value::Object(part) = p + && let Some(Value::Object(iv)) = part.get_mut("input_video") + { + for key in ["url", "data"] { + if let Some(Value::String(u)) = iv.get(key) + && u.starts_with("data:") + { + let e = elide_data_uri(u); + iv.insert(key.into(), Value::String(e)); + } + } + } } } m diff --git a/rust/loki-rs/src/imagetools.rs b/rust/loki-rs/src/imagetools.rs index 837e5ed..186576f 100644 --- a/rust/loki-rs/src/imagetools.rs +++ b/rust/loki-rs/src/imagetools.rs @@ -225,6 +225,169 @@ pub async fn see_image( ) } +// --- see_video (chat_video.go, chat_video_tool.go) ------------------------------ + +/// `maxVideoBytes` : au-delà, des dizaines de milliers de jetons visuels. +pub const MAX_VIDEO_BYTES: i64 = 200 << 20; + +/// `videoFallbackNote` : consigne quand le moteur ne lit pas la vidéo. +pub const VIDEO_FALLBACK_NOTE: &str = "Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.\n"; + +/// `videoMimes` : les vidéos qu'on sait montrer au modèle. +pub fn video_mime(name: &str) -> &'static str { + let ext = Path::new(name) + .extension() + .map(|e| format!(".{}", e.to_string_lossy().to_lowercase())) + .unwrap_or_default(); + match ext.as_str() { + ".mp4" => "video/mp4", + ".webm" => "video/webm", + ".mov" => "video/quicktime", + ".mkv" => "video/x-matroska", + ".avi" => "video/x-msvideo", + _ => "", + } +} + +/// `videoPart` : la partie `input_video` que lit llama-server. +pub fn video_part(b: &[u8], mime: &str) -> Value { + json!({ + "type": "input_video", + "input_video": { "url": format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(b)) }, + }) +} + +/// `videoInputSupported` : preset local avec projecteur (MMPROJ) et moteur +/// dont l'aide annonce `--video-fps`. Lit le disque et lance le moteur : +/// à appeler hors du runtime (`spawn_blocking`). `home` : `LOKI_HOME`. +pub fn video_input_supported(cfg: &crate::serve::Cfg, home: &Path) -> bool { + use crate::serve::{get, trim}; + if crate::serve::is_external(cfg) + || crate::serve::is_strata(cfg) + || trim(get(cfg, "MMPROJ")).is_empty() + { + return false; + } + let mut bin = trim(get(cfg, "BIN")).to_string(); + if bin.is_empty() { + return false; + } + if !crate::paths::go_is_abs(&bin) { + bin = home.join(&bin).to_string_lossy().into_owned(); + } + let ep = crate::serve::run::EnginePaths { + home: home.to_path_buf(), + }; + bin_help_has_video(&ep.prebuilt_resolve_bin(&bin)) +} + +/// `binHelpHasVideo` : l'aide du binaire, une fois par binaire et par date +/// de modification, chemin des bibliothèques passé à la seule commande. +pub fn bin_help_has_video(bin: &str) -> bool { + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + static CACHE: OnceLock>> = OnceLock::new(); + let Ok(st) = std::fs::metadata(bin) else { + return false; + }; + if st.is_dir() { + return false; + } + let key = format!("{bin}\0{:?}", st.modified().ok()); + let cache = CACHE.get_or_init(Default::default); + if let Some(v) = cache.lock().ok().and_then(|c| c.get(&key).copied()) { + return v; + } + let mut env = crate::serve::run::Env::default(); + let dir = Path::new(bin) + .parent() + .map(|d| d.to_string_lossy().into_owned()) + .unwrap_or_default(); + let (k, v) = crate::serve::run::library_path_value(&env, &dir); + env.set(&k, &v); + let help = crate::serve::run::bin_help(&env, bin); + let ok = help.contains("--video-fps"); + if let Ok(mut c) = cache.lock() { + c.insert(key, ok); + } + ok +} + +/// `toolSeeVideo` : (accusé, partie vidéo). Même ordre de refus que +/// [`see_image`] ; `video_ok` : [`video_input_supported`], `sees` : la sonde +/// du moteur, appelée en dernier. +pub async fn see_video( + path: &str, + cwd: &Path, + target: &str, + vision: bool, + video_ok: bool, + sees: impl std::future::Future, +) -> (String, Option) { + let err = |m: String| (m, None); + if !vision { + return err("[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo".into()); + } + if !target.is_empty() { + return err("[erreur] voir une vidéo n'est pas possible sur un poste distant (le fichier est sur l'autre machine)".into()); + } + if path.is_empty() { + return err("[erreur] chemin de fichier manquant".into()); + } + if !video_ok { + return err(format!( + "[erreur] {}", + VIDEO_FALLBACK_NOTE.trim_end_matches('\n') + )); + } + let abs = crate::tools::policy::resolve_agent_path(cwd, path); + let mime = video_mime(&abs.to_string_lossy()); + if mime.is_empty() { + return err(format!( + "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : {path}" + )); + } + let st = match std::fs::metadata(&abs) { + Ok(m) => m, + Err(_) => return err(format!("[erreur] fichier introuvable : {path}")), + }; + if st.is_dir() { + return err(format!("[erreur] c'est un dossier, pas une vidéo : {path}")); + } + if st.len() as i64 > MAX_VIDEO_BYTES { + return err(format!( + "[erreur] vidéo trop lourde ({}, max {}) — coupe-la en un clip plus court : {path}", + crate::downloads::human_bytes(st.len() as i64), + crate::downloads::human_bytes(MAX_VIDEO_BYTES) + )); + } + if !sees.await { + return err(format!( + "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir {path}" + )); + } + let b = match std::fs::read(&abs) { + Ok(b) => b, + Err(e) => { + return err(format!( + "[erreur] lecture impossible : {}", + crate::paths::os_err("open", &abs.to_string_lossy(), &e) + )); + } + }; + if b.is_empty() { + return err(format!("[erreur] fichier vide : {path}")); + } + let base = abs + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + ( + format!("[ok] vidéo chargée : {base}"), + Some(video_part(&b, mime)), + ) +} + // --- web_screenshot ------------------------------------------------------------- /// Le résultat de `web_screenshot` : le texte, et la capture rangée diff --git a/rust/loki-rs/tests/parity_images/video.json b/rust/loki-rs/tests/parity_images/video.json new file mode 100644 index 0000000..8b68fdc --- /dev/null +++ b/rust/loki-rs/tests/parity_images/video.json @@ -0,0 +1,245 @@ +{ + "files": { + "CLIP.MOV": "AAAAFGZ0eXBxdCAgIG1vdg==", + "anim.webm": "GkXfoyB3ZWJt", + "clip.mp4": "AAAAGGZ0eXBtcDQyIGZhdXNzZSB2aWTDqW8=", + "film.mkv": "GkXfoyBtYXRyb3NrYQ==", + "sous/court.mp4": "ZnR5cCBzb3VzLWRvc3NpZXI=", + "texte.txt": "cGFzIHVuZSB2aWTDqW8=", + "vide.mp4": "", + "vieux.avi": "UklGRhAAAABBVkkgTElTVA==" + }, + "steps": [ + { + "name": "sans vision", + "video": true, + "args": { + "file": "clip.mp4" + }, + "label": "clip.mp4", + "result": "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo", + "probes": 0 + }, + { + "name": "chemin manquant", + "vision": true, + "video": true, + "args": { + "file": "" + }, + "label": "", + "result": "[erreur] chemin de fichier manquant", + "probes": 0 + }, + { + "name": "sans argument", + "vision": true, + "video": true, + "args": {}, + "label": "", + "result": "[erreur] chemin de fichier manquant", + "probes": 0 + }, + { + "name": "moteur sans vidéo", + "vision": true, + "sees": true, + "args": { + "file": "clip.mp4" + }, + "label": "clip.mp4", + "result": "[erreur] Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.", + "probes": 0 + }, + { + "name": "pas une vidéo", + "vision": true, + "video": true, + "args": { + "file": "texte.txt" + }, + "label": "texte.txt", + "result": "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : texte.txt", + "probes": 0 + }, + { + "name": "image", + "vision": true, + "video": true, + "args": { + "file": "photo.png" + }, + "label": "photo.png", + "result": "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : photo.png", + "probes": 0 + }, + { + "name": "introuvable", + "vision": true, + "video": true, + "args": { + "file": "absente.mp4" + }, + "label": "absente.mp4", + "result": "[erreur] fichier introuvable : absente.mp4", + "probes": 0 + }, + { + "name": "dossier", + "vision": true, + "video": true, + "args": { + "file": "dossier.mp4" + }, + "label": "dossier.mp4", + "result": "[erreur] c'est un dossier, pas une vidéo : dossier.mp4", + "probes": 0 + }, + { + "name": "trop lourde", + "vision": true, + "video": true, + "args": { + "file": "lourd.mp4" + }, + "label": "lourd.mp4", + "result": "[erreur] vidéo trop lourde (200 Mo, max 200 Mo) — coupe-la en un clip plus court : lourd.mp4", + "probes": 0 + }, + { + "name": "le moteur ne voit pas", + "vision": true, + "video": true, + "args": { + "file": "clip.mp4" + }, + "label": "clip.mp4", + "result": "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir clip.mp4", + "probes": 1 + }, + { + "name": "vide", + "vision": true, + "sees": true, + "video": true, + "args": { + "file": "vide.mp4" + }, + "label": "vide.mp4", + "result": "[erreur] fichier vide : vide.mp4", + "probes": 1 + }, + { + "name": "voir clip.mp4", + "vision": true, + "sees": true, + "video": true, + "args": { + "file": "clip.mp4" + }, + "label": "clip.mp4", + "result": "[ok] vidéo chargée : clip.mp4\n[relais] Vidéo demandée (clip.mp4) :", + "part": { + "mime": "video/mp4", + "same_as": "clip.mp4" + }, + "probes": 1 + }, + { + "name": "voir CLIP.MOV", + "vision": true, + "sees": true, + "video": true, + "args": { + "file": "CLIP.MOV" + }, + "label": "CLIP.MOV", + "result": "[ok] vidéo chargée : CLIP.MOV\n[relais] Vidéo demandée (CLIP.MOV) :", + "part": { + "mime": "video/quicktime", + "same_as": "CLIP.MOV" + }, + "probes": 1 + }, + { + "name": "voir anim.webm", + "vision": true, + "sees": true, + "video": true, + "args": { + "file": "anim.webm" + }, + "label": "anim.webm", + "result": "[ok] vidéo chargée : anim.webm\n[relais] Vidéo demandée (anim.webm) :", + "part": { + "mime": "video/webm", + "same_as": "anim.webm" + }, + "probes": 1 + }, + { + "name": "voir film.mkv", + "vision": true, + "sees": true, + "video": true, + "args": { + "file": "film.mkv" + }, + "label": "film.mkv", + "result": "[ok] vidéo chargée : film.mkv\n[relais] Vidéo demandée (film.mkv) :", + "part": { + "mime": "video/x-matroska", + "same_as": "film.mkv" + }, + "probes": 1 + }, + { + "name": "voir vieux.avi", + "vision": true, + "sees": true, + "video": true, + "args": { + "file": "vieux.avi" + }, + "label": "vieux.avi", + "result": "[ok] vidéo chargée : vieux.avi\n[relais] Vidéo demandée (vieux.avi) :", + "part": { + "mime": "video/x-msvideo", + "same_as": "vieux.avi" + }, + "probes": 1 + }, + { + "name": "voir sous/court.mp4", + "vision": true, + "sees": true, + "video": true, + "args": { + "file": "sous/court.mp4" + }, + "label": "sous/court.mp4", + "result": "[ok] vidéo chargée : court.mp4\n[relais] Vidéo demandée (sous/court.mp4) :", + "part": { + "mime": "video/mp4", + "same_as": "sous/court.mp4" + }, + "probes": 1 + }, + { + "name": "chemin absolu", + "vision": true, + "sees": true, + "video": true, + "args": { + "file": "{{FICHIERS}}/clip.mp4" + }, + "label": "{{FICHIERS}}/clip.mp4", + "result": "[ok] vidéo chargée : clip.mp4\n[relais] Vidéo demandée ({{FICHIERS}}/clip.mp4) :", + "part": { + "mime": "video/mp4", + "same_as": "clip.mp4" + }, + "probes": 1 + } + ] +} diff --git a/rust/loki-rs/tests/videotools.rs b/rust/loki-rs/tests/videotools.rs new file mode 100644 index 0000000..71cacc2 --- /dev/null +++ b/rust/loki-rs/tests/videotools.rs @@ -0,0 +1,231 @@ +//! see_video (repris d'AJEAN) tenu par la façade, rejoué sur le journal du +//! Go (`internal/loki/parity_video_tool_test.go` → +//! `tests/parity_images/video.json`) : mêmes fichiers, même faux moteur +//! (`/props`), même capacité vidéo du moteur, étape par étape. La vidéo +//! relayée se compare à l'octet (jamais réencodée). + +mod common; + +use std::convert::Infallible; +use std::sync::{Arc, Mutex}; + +use base64::Engine as _; +use bytes::Bytes; +use common::fakego; +use http_body_util::Full; +use hyper::service::service_fn; +use hyper::{Request, Response}; +use hyper_util::rt::TokioIo; +use loki_rs::imagetools::{self, VisionProbe}; +use loki_rs::store::Store; +use serde_json::{Map, Value, json}; +use tokio::net::TcpListener; + +/// Le faux moteur : `/props` authentifié, la vision réglée par le test. +async fn fake_engine(sees: Arc>) -> u16 { + let l = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = l.local_addr().unwrap().port(); + tokio::spawn(async move { + while let Ok((st, _)) = l.accept().await { + let sees = sees.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req: Request| { + let sees = sees.clone(); + async move { + let ok = req.uri().path() == "/props" + && req + .headers() + .get("authorization") + .and_then(|h| h.to_str().ok()) + == Some("Bearer cle-moteur"); + let mut r = Response::new(Full::new(Bytes::new())); + if ok { + let mut s = sees.lock().unwrap(); + s.1 += 1; + r = Response::new(Full::new(Bytes::from( + json!({ "modalities": { "vision": s.0 } }).to_string(), + ))); + } else { + *r.status_mut() = hyper::StatusCode::UNAUTHORIZED; + } + Ok::<_, Infallible>(r) + } + }); + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(TokioIo::new(st), svc) + .await; + }); + } + }); + port +} + +/// `parityVideoFacts`. +fn part_facts(part: &Value, raw: &Map) -> Value { + let u = part["input_video"]["url"].as_str().unwrap(); + let i = u.find(";base64,").unwrap(); + let b = base64::engine::general_purpose::STANDARD + .decode(&u[i + 8..]) + .unwrap(); + let mut out = Map::new(); + out.insert("mime".into(), json!(u[..i].trim_start_matches("data:"))); + for (name, r) in raw { + let rb = base64::engine::general_purpose::STANDARD + .decode(r.as_str().unwrap_or("")) + .unwrap(); + if rb == b { + out.insert("same_as".into(), json!(name)); + } + } + Value::Object(out) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn see_video_comme_le_go() { + let fx: Value = + serde_json::from_str(include_str!("parity_images/video.json")).expect("journal"); + let tmp = tempfile::tempdir().unwrap(); + let files = tmp.path().join("fichiers"); + let raw = fx["files"].as_object().unwrap(); + for (name, b) in raw { + let p = files.join(name); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write( + &p, + base64::engine::general_purpose::STANDARD + .decode(b.as_str().unwrap_or("")) + .unwrap(), + ) + .unwrap(); + } + std::fs::create_dir_all(files.join("dossier.mp4")).unwrap(); + std::fs::File::create(files.join("lourd.mp4")) + .unwrap() + .set_len((imagetools::MAX_VIDEO_BYTES + 1) as u64) + .unwrap(); + let files_s = files.to_string_lossy().replace('\\', "/"); + + let sees = Arc::new(Mutex::new((false, 0u32))); + let port = fake_engine(sees.clone()).await; + let (_, go, _stop) = fakego::start(tmp.path().join("home")).await; + let backend = go.backend.lock().unwrap().unwrap(); + let store = Store::new(backend, fakego::JETON.into()); + go.set("state", "api_key", "cle-moteur"); + let mut cfg = std::collections::HashMap::new(); + cfg.insert("PORT".to_string(), port.to_string()); + let probe = VisionProbe::default(); + + for st in fx["steps"].as_array().unwrap() { + let name = st["name"].as_str().unwrap(); + *sees.lock().unwrap() = (st["sees"] == true, 0); + probe.reset().await; + let label = st["label"] + .as_str() + .unwrap() + .replace("{{FICHIERS}}", &files_s); + let (mut result, vid) = imagetools::see_video( + &label, + &files, + "", + st["vision"] == true, + st["video"] == true, + probe.sees(&store, &cfg), + ) + .await; + let relay = vid.map(|v| loki_rs::chat::host::see_video_message(&label, v)); + let mut facts = None; + if let Some(parts) = relay + .as_ref() + .and_then(|m| m.content.as_ref()) + .and_then(Value::as_array) + { + for p in parts { + if p["type"] == "input_video" { + facts = Some(part_facts(p, raw)); + } + if p["type"] == "text" { + result += &format!("\n[relais] {}", p["text"].as_str().unwrap()); + } + } + } + assert_eq!( + result.replace(&files_s, "{{FICHIERS}}"), + st["result"].as_str().unwrap(), + "{name}" + ); + assert_eq!(facts, st.get("part").cloned(), "{name} : vidéo relayée"); + assert_eq!( + sees.lock().unwrap().1 as u64, + st["probes"].as_u64().unwrap(), + "{name} : sondes du moteur" + ); + } +} + +/// `videoInputSupported` : jamais pour une API externe, Strata, un preset +/// sans projecteur ou sans moteur ; sinon l'aide du binaire fait foi, relue +/// quand il change. +#[test] +fn capacite_video_du_moteur() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = |kv: &[(&str, &str)]| -> std::collections::HashMap { + kv.iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + }; + for c in [ + cfg(&[("BIN", "llama-server"), ("MODEL", "m.gguf")]), + cfg(&[("MMPROJ", "mm.gguf"), ("MODEL", "m.gguf")]), + cfg(&[("MMPROJ", "mm.gguf"), ("BIN", "absent/llama-server")]), + cfg(&[ + ("MMPROJ", "mm.gguf"), + ("EXTERNAL", "1"), + ("EXTERNAL_VISION", "1"), + ]), + cfg(&[ + ("MMPROJ", "mm.gguf"), + ("ENGINE", "strata"), + ("BIN", "/bin/sh"), + ]), + ] { + assert!( + !imagetools::video_input_supported(&c, tmp.path()), + "{c:?} : vidéo annoncée" + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let bin = tmp.path().join("llama-server"); + let write = |help: &str, age: u64| { + std::fs::write(&bin, format!("#!/bin/sh\necho '{help}'\n")).unwrap(); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); + let f = std::fs::File::options().write(true).open(&bin).unwrap(); + f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age)) + .unwrap(); + }; + let c = cfg(&[("MMPROJ", "mm.gguf"), ("BIN", "llama-server")]); + write("--mmproj FILE", 3600); + assert!(!imagetools::video_input_supported(&c, tmp.path())); + write("--video-fps N target fps", 0); + assert!( + imagetools::video_input_supported(&c, tmp.path()), + "moteur mis à jour : --video-fps non relu" + ); + } +} + +#[test] +fn types_de_video() { + for (f, m) in [ + ("a.mp4", "video/mp4"), + ("A.MOV", "video/quicktime"), + ("x/b.webm", "video/webm"), + ("c.mkv", "video/x-matroska"), + ("d.avi", "video/x-msvideo"), + ("e.png", ""), + ("mp4", ""), + ] { + assert_eq!(imagetools::video_mime(f), m, "{f}"); + } +}