mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Façade : see_video exécuté par la façade, vidéos jointes rangées relues
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
776de75dd0
commit
ac74ecde0b
12 files changed
+1084
-42
No files matched your search
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
+101
-16
@@ -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<String, String>,
|
||||
@@ -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<String> {
|
||||
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.
|
||||
|
||||
@@ -141,10 +141,13 @@ pub fn steer_system(msgs: &[Message], hint: &str) -> Vec<Message> {
|
||||
}
|
||||
|
||||
/// `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à
|
||||
|
||||
@@ -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<T: DeserializeOwned>(v: Value) -> Vec<T> {
|
||||
/// `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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ pub fn tool_call_label(name: &str, args: &Map<String, Value>) -> 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()
|
||||
|
||||
@@ -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<String>) -> Message {
|
||||
Message {
|
||||
role: role.into(),
|
||||
@@ -1071,8 +1078,11 @@ async fn run_turn_loop<H: TurnHost>(
|
||||
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) => {
|
||||
|
||||
@@ -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<String, Value>) -> &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<String, Value>) -> &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<String>,
|
||||
) -> Cow<'a, [Message]> {
|
||||
let is_ref = |p: &Map<String, Value>| {
|
||||
part_image_url(p).starts_with(IMG_REF_SCHEME)
|
||||
|| part_video_url(p).starts_with(VID_REF_SCHEME)
|
||||
};
|
||||
let mut out: Option<Vec<Message>> = 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<Value> = 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]
|
||||
|
||||
@@ -194,6 +194,20 @@ pub fn strip_inline_images(msgs: Vec<Message>) -> Vec<Message> {
|
||||
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
|
||||
|
||||
@@ -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 <vidéo> -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<Mutex<HashMap<String, bool>>> = 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<Output = bool>,
|
||||
) -> (String, Option<Value>) {
|
||||
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
|
||||
|
||||
@@ -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 <vidéo> -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<Mutex<(bool, u32)>>) -> 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<hyper::body::Incoming>| {
|
||||
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<String, Value>) -> 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<String, String> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user