diff --git a/internal/loki/backend_build.go b/internal/loki/backend_build.go index cb8ca79..5f12a7f 100644 --- a/internal/loki/backend_build.go +++ b/internal/loki/backend_build.go @@ -44,20 +44,45 @@ func emitBuildLine(line string) { // Sert à téer la sortie des commandes de runStep quand un sink est actif. // (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle) type sinkWriter struct { - mu sync.Mutex - buf []byte + mu sync.Mutex + buf []byte + lastProg time.Time // dernière ligne de progression (\r) émise } +// sinkProgressEvery : une barre de progression (tqdm, téléchargements de +// l'installeur de Strata) se redessine sur place avec \r, sans jamais finir sa +// ligne. On en montre une version toutes les 10 s : sans elle, un téléchargement +// de 76 Go laissait le journal figé pendant une heure ; toutes, elles le noieraient. +const sinkProgressEvery = 10 * time.Second + func (s *sinkWriter) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() s.buf = append(s.buf, p...) for { - i := strings.IndexByte(string(s.buf), '\n') + i := strings.IndexAny(string(s.buf), "\r\n") if i < 0 { break } - emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r")) + if s.buf[i] == '\n' { + emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r")) + s.buf = s.buf[i+1:] + continue + } + // \r : fin de ligne Windows (\r\n) ou barre qui se redessine. Le \n peut + // arriver dans l'écriture suivante : on attend de le savoir. + if i+1 >= len(s.buf) { + break + } + if s.buf[i+1] == '\n' { + emitBuildLine(string(s.buf[:i])) + s.buf = s.buf[i+2:] + continue + } + if prog := strings.TrimSpace(string(s.buf[:i])); prog != "" && time.Since(s.lastProg) >= sinkProgressEvery { + s.lastProg = time.Now() + emitBuildLine(prog) + } s.buf = s.buf[i+1:] } return len(p), nil diff --git a/internal/loki/backend_build_sink_test.go b/internal/loki/backend_build_sink_test.go new file mode 100644 index 0000000..006e578 --- /dev/null +++ b/internal/loki/backend_build_sink_test.go @@ -0,0 +1,29 @@ +package loki + +import ( + "reflect" + "testing" + "time" +) + +// Une barre de progression (\r sans \n) doit apparaître dans le journal, sans +// le noyer ; \r\n reste une fin de ligne ordinaire, même coupé entre deux écritures. +func TestSinkWriterProgression(t *testing.T) { + var got []string + setBuildSink(func(l string) { got = append(got, l) }) + defer setBuildSink(nil) + + s := &sinkWriter{} + s.Write([]byte("Step 5\r")) + s.Write([]byte("\n modèle : 1 / 76 GB\r modèle : 2 / 76 GB\r")) + s.Write([]byte(" modèle : 3 / 76 GB\r")) + s.lastProg = time.Now().Add(-time.Minute) // 10 s écoulées + s.Write([]byte(" modèle : 40 / 76 GB\r[ok] fini\n")) + + // « 3 » finissait sa propre écriture : il attendait de savoir si un saut de + // ligne suivait, et part donc avec l'écriture suivante. + want := []string{"Step 5", "modèle : 1 / 76 GB", "modèle : 3 / 76 GB", "[ok] fini"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("lignes %q, attendu %q", got, want) + } +} diff --git a/internal/loki/chat_cmd.go b/internal/loki/chat_cmd.go index 3b59da3..3a6552c 100644 --- a/internal/loki/chat_cmd.go +++ b/internal/loki/chat_cmd.go @@ -115,7 +115,7 @@ func cmdChat(args []string) error { case "edit": icon = "✏️" verb = "édition" - case "web_search", "web_open", "web_read", "web_grep": + case "web_search", "web_images", "web_open", "web_read", "web_grep": icon = "🌐" verb = "web" case "web_screenshot": diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 5078819..4929e55 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -354,6 +354,17 @@ func compactLog(log []LogEvent) []LogEvent { if tu, ok := ev.Delta["tool_used"].(map[string]any); ok { flush() if done, _ := tu["done"].(bool); done { + // L'aperçu du navigateur (shot) n'est que du direct : on ne le + // garde pas une fois le tour fini (voir cu_autoshot.go). + if _, has := tu["shot"]; has { + cp := make(map[string]any, len(tu)) + for k, v := range tu { + if k != "shot" { + cp[k] = v + } + } + ev.Delta = map[string]any{"tool_used": cp} + } out = append(out, ev) } continue @@ -945,6 +956,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa if ev.ToolUsed.Image != "" { tu["image"] = ev.ToolUsed.Image } + if ev.ToolUsed.Shot != "" { + tu["shot"] = ev.ToolUsed.Shot + } // Aperçu seulement dans le flux : taille réelle + id pour « voir plus ». if ev.ToolUsed.ResultChars > 0 { tu["result_chars"] = ev.ToolUsed.ResultChars diff --git a/internal/loki/chat_internet_tools.go b/internal/loki/chat_internet_tools.go index 5c43d50..1313f8c 100644 --- a/internal/loki/chat_internet_tools.go +++ b/internal/loki/chat_internet_tools.go @@ -297,7 +297,7 @@ func cmdInternet(args []string) error { if err := setInternetEnabled(true); err != nil { return err } - fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_open/web_read/web_grep (si le mode agent est actif)") + fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_images/web_open/web_read/web_grep (si le mode agent est actif)") case "off": if err := setInternetEnabled(false); err != nil { return err @@ -352,7 +352,7 @@ func cmdInternet(args []string) error { fmt.Printf("%s état: %s\n", cyan("Accès internet"), state) if webEngine() == engineGo { fmt.Printf(" moteur : %s (aucune installation, pas de rendu JavaScript)\n", bold("intégré")) - fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n") + fmt.Printf(" outils : web_search, web_images, web_open, web_read, web_grep\n") return nil } fmt.Printf(" moteur : %s\n", bold("crawl4ai")) @@ -366,7 +366,7 @@ func cmdInternet(args []string) error { reach = green("joignable") } fmt.Printf(" serveur : %s (%s)\n", bold(u), reach) - fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n") + fmt.Printf(" outils : web_search, web_images, web_open, web_read, web_grep\n") default: return fmt.Errorf("usage: loki internet [on|off|status|engine |url |key ]") } diff --git a/internal/loki/chat_web_images.go b/internal/loki/chat_web_images.go new file mode 100644 index 0000000..f8442e5 --- /dev/null +++ b/internal/loki/chat_web_images.go @@ -0,0 +1,139 @@ +package loki + +// web_images : la recherche d'images DuckDuckGo, pendant de web_search. Sans +// elle, « montre-moi des photos de X » obligeait le modèle à ouvrir des pages +// dans le navigateur et à en renvoyer des captures (lent, et des images de +// pages au lieu de photos). +// +// DuckDuckGo sert les images par i.js, qui exige un jeton « vqd » lu dans la +// page de recherche. Même client HTTP que le moteur intégré, quel que soit le +// moteur web actif : Crawl4AI n'apporterait rien à un appel JSON. + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" +) + +func webImagesTool() Tool { + return Tool{Type: "function", Function: ToolFunction{ + // Schéma minimal : le préambule a un budget (chat_prompt_size_test.go). + // limit n'est pas annoncé ; toolWebImages l'accepte encore (6 par défaut). + Name: "web_images", + Description: "Find images.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + }, + "required": []string{"query"}, + }, + }} +} + +type imageResult struct { + Image string `json:"image"` + Title string `json:"title"` + URL string `json:"url"` + Width int `json:"width"` + Height int `json:"height"` +} + +var ddgVqdRe = regexp.MustCompile(`vqd=["']?([0-9-]+)`) + +func ddgGet(ctx context.Context, target, referer string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", goFetchUA) + req.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8") + if referer != "" { + req.Header.Set("Referer", referer) + req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01") + } + resp, err := goHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("échec de la requête : %v", err) + } + defer resp.Body.Close() + b, err := io.ReadAll(io.LimitReader(resp.Body, goFetchMaxBytes)) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + return b, nil +} + +func duckduckgoImages(query string, limit int) ([]imageResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout) + defer cancel() + page := "https://duckduckgo.com/?q=" + url.QueryEscape(query) + "&iax=images&ia=images" + body, err := ddgGet(ctx, page, "") + if err != nil { + return nil, err + } + m := ddgVqdRe.FindSubmatch(body) + if m == nil { + return nil, fmt.Errorf("jeton DuckDuckGo introuvable (défi anti-bot ?)") + } + api := "https://duckduckgo.com/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1]) + body, err = ddgGet(ctx, api, "https://duckduckgo.com/") + if err != nil { + return nil, err + } + var r struct { + Results []imageResult `json:"results"` + } + if err := json.Unmarshal(body, &r); err != nil { + return nil, fmt.Errorf("réponse illisible : %v", err) + } + var out []imageResult + seen := map[string]bool{} + for _, it := range r.Results { + if it.Image == "" || seen[it.Image] || !strings.HasPrefix(it.Image, "http") { + continue + } + seen[it.Image] = true + out = append(out, it) + if len(out) >= limit { + break + } + } + return out, nil +} + +func toolWebImages(args map[string]any) string { + query, _ := args["query"].(string) + limit := 6 + if v, ok := args["limit"].(float64); ok { + limit = int(v) + } + if limit < 1 { + limit = 1 + } + if limit > 20 { + limit = 20 + } + results, err := duckduckgoImages(query, limit) + if err != nil { + return "❌ Recherche d'images échouée : " + err.Error() + } + if len(results) == 0 { + return fmt.Sprintf("Aucune image pour « %s »", query) + } + var b strings.Builder + fmt.Fprintf(&b, "# Images : %s\n%d résultat(s) DuckDuckGo\n\n", query, len(results)) + for i, r := range results { + fmt.Fprintf(&b, "%d. %s (%dx%d)\n image : %s\n page : %s\n\n", i+1, r.Title, r.Width, r.Height, r.Image, r.URL) + } + b.WriteString("To show an image to the user, write ![title](image URL) in your answer.") + return b.String() +} diff --git a/internal/loki/computer_cdp.go b/internal/loki/computer_cdp.go index 1b82b19..462aafe 100644 --- a/internal/loki/computer_cdp.go +++ b/internal/loki/computer_cdp.go @@ -225,6 +225,7 @@ func cdpLaunch() (*cdpSession, error) { args = append([]string{"--headless=new"}, args...) } cmd := exec.Command(bin, args...) + prepareBoundChild(cmd) // tué avec Loki, même brutalement (sys_childbind_*.go) if err := cmd.Start(); err != nil { os.RemoveAll(userDir) return nil, err diff --git a/internal/loki/cu_autoshot.go b/internal/loki/cu_autoshot.go new file mode 100644 index 0000000..3e8a79a --- /dev/null +++ b/internal/loki/cu_autoshot.go @@ -0,0 +1,92 @@ +package loki + +// cu_autoshot.go — aperçu EN DIRECT du navigateur piloté (computer use). Après +// chaque action browser_*, on capture la page pour que l'UTILISATEUR voie ce que +// fait l'IA. C'est de l'interface seulement : +// - jamais envoyé au modèle (zéro token, vision non requise) ; +// - jamais écrit sur le disque : un petit anneau en mémoire vive ; +// - retiré du journal en fin de tour (compactLogLocked), et l'UI efface la carte +// à la fin du tour. Une capture à GARDER passe toujours par +// browser_screenshot, qui l'enregistre dans le dossier de travail. + +import ( + "bytes" + "encoding/base64" + "fmt" + "image" + "image/jpeg" + _ "image/png" + "net/http" + "os" + "strings" + "sync" + "time" +) + +const cuShotRing = 8 // captures gardées en RAM (l'UI n'affiche que la dernière) + +var ( + cuShotMu sync.Mutex + cuShotData = map[string][]byte{} + cuShotIDs []string +) + +// cuAutoShotTool : outils après lesquels on capture (ceux qui changent la page). +func cuAutoShotTool(name string) bool { + switch name { + case "browser_open", "browser_click", "browser_click_xy", "browser_type", "browser_key", "browser_scroll", "browser_find": + return true + } + return false +} + +func cuLog(format string, a ...any) { fmt.Fprintf(os.Stderr, "[cu] "+format+"\n", a...) } + +// cuAutoShot capture la page en pleine résolution (JPEG de bonne qualité) et +// renvoie l'identifiant de la capture en mémoire ("" si impossible : jamais +// bloquant pour l'outil). +func cuAutoShot() string { + s, err := cdpGet() + if err != nil { + cuLog("aperçu : navigateur indisponible : %v", err) + return "" + } + raw, err := s.screenshot() + if err != nil { + cuLog("aperçu : capture impossible : %v", err) + return "" + } + img, _, err := image.Decode(bytes.NewReader(raw)) + if err != nil { + cuLog("aperçu : image illisible (%d octets) : %v", len(raw), err) + return "" + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil { + return "" + } + id := fmt.Sprintf("%x", time.Now().UnixNano()) + cuShotMu.Lock() + cuShotData[id] = buf.Bytes() + cuShotIDs = append(cuShotIDs, id) + for len(cuShotIDs) > cuShotRing { + delete(cuShotData, cuShotIDs[0]) + cuShotIDs = cuShotIDs[1:] + } + cuShotMu.Unlock() + return id +} + +// handleCUShot (GET ?id=) : une capture en base64 (JSON, pour traverser aussi le +// tunnel chiffré du relais). 404 si elle est déjà sortie de l'anneau. +func handleCUShot(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.URL.Query().Get("id")) + cuShotMu.Lock() + b := cuShotData[id] + cuShotMu.Unlock() + if b == nil { + sendJSON(w, 404, map[string]any{"ok": false, "error": "capture expirée"}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "mime": "image/jpeg", "data": base64.StdEncoding.EncodeToString(b)}) +} diff --git a/internal/loki/cu_autoshot_log_test.go b/internal/loki/cu_autoshot_log_test.go new file mode 100644 index 0000000..32e0a08 --- /dev/null +++ b/internal/loki/cu_autoshot_log_test.go @@ -0,0 +1,39 @@ +package loki + +import "testing" + +// L'aperçu du navigateur (shot) n'est que du direct : la compaction de fin de +// tour le retire du journal, le reste de l'événement d'outil est gardé. +func TestCompactLogRetireLAperçuNavigateur(t *testing.T) { + log := []LogEvent{ + {Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "browser_click", "done": false}}}, + {Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "browser_click", "done": true, "result": "ok", "shot": "abc"}}}, + } + out := compactLog(log) + if len(out) != 1 { + t.Fatalf("un seul événement d'outil attendu, %d", len(out)) + } + tu := out[0].Delta["tool_used"].(map[string]any) + if _, has := tu["shot"]; has { + t.Fatal("l'aperçu aurait dû quitter le journal") + } + if tu["result"] != "ok" || tu["name"] != "browser_click" { + t.Fatalf("événement abîmé : %v", tu) + } + if _, has := log[1].Delta["tool_used"].(map[string]any)["shot"]; !has { + t.Fatal("le journal d'origine ne doit pas être modifié en place") + } +} + +func TestCUAutoShotTool(t *testing.T) { + for _, n := range []string{"browser_open", "browser_click", "browser_type", "browser_scroll"} { + if !cuAutoShotTool(n) { + t.Errorf("%s devrait déclencher l'aperçu", n) + } + } + for _, n := range []string{"browser_screenshot", "browser_snapshot", "bash", "web_images"} { + if cuAutoShotTool(n) { + t.Errorf("%s ne devrait pas déclencher l'aperçu", n) + } + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index db4ceae..8bacd77 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -494,7 +494,7 @@ func EnabledTools(caps Caps) []Tool { // caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go), // donc prompt et outils restent cohérents — pas de web_search halluciné. if caps.Agent && caps.Internet { - tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool()) + tools = append(tools, webSearchTool(), webImagesTool(), webOpenTool(), webReadTool(), webGrepTool()) } // Capture d'écran : seulement si Playwright est réellement présent dans // l'image (annoncer un outil absent enverrait le modèle en boucle de @@ -602,6 +602,10 @@ type ToolUsedEvent struct { // pensait à recopier la ligne markdown rendue par l'outil : un petit modèle // l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée. Image string + // Shot : identifiant de la capture du navigateur prise après une action + // browser_* (cu_autoshot.go). Aperçu EN DIRECT seulement : gardé en RAM, + // jamais envoyé au modèle, retiré du journal en fin de tour. + Shot string // ResultChars : taille RÉELLE du résultat (en runes), même quand Result n'en // porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle. ResultChars int @@ -1103,7 +1107,7 @@ func isNetTimeout(err error) bool { func toolCallLabel(name string, args map[string]any) string { label := "" switch name { - case "mem_search", "web_search", "recall_search": + case "mem_search", "web_search", "web_images", "recall_search": label, _ = args["query"].(string) case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image": label, _ = args["file"].(string) @@ -1876,7 +1880,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools if cur := toolCalls[0]; cur != nil { key := "command" switch cur.Function.Name { - case "mem_search", "web_search", "recall_search": + case "mem_search", "web_search", "web_images", "recall_search": key = "query" case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff": key = "file" @@ -2494,6 +2498,8 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools } case "web_search": result = capWebOutput(toolWebSearch(args)) + case "web_images": + result = capWebOutput(toolWebImages(args)) case "web_open": result = capWebOutput(toolWebOpen(args)) case "web_read": @@ -2535,8 +2541,14 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools result = "[erreur] outil inconnu: " + tc.Function.Name } } + // Aperçu en direct du navigateur piloté (repris d'AJEAN) : une + // capture après chaque action qui change la page, pour l'UI seule. + liveShot := "" if !strings.HasPrefix(result, "[erreur]") { doneCalls[callKey] = result + if cuAutoShotTool(tc.Function.Name) { + liveShot = cuAutoShot() + } } // Capture d'écran : l'image part avec l'événement pour être // affichée dans la bulle, quoi que le modèle en fasse ensuite. @@ -2544,7 +2556,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools if tc.Function.Name == "web_screenshot" { shot = capturedRelPath(result) } - cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot}, result)}) + cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot, Shot: liveShot}, result)}) // Plafond pour le MODÈLE seulement : l'UI vient de recevoir le // résultat complet (aperçu + « voir plus »). toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: capToolResult(result)} diff --git a/internal/loki/sys_childbind_linux.go b/internal/loki/sys_childbind_linux.go new file mode 100644 index 0000000..cefb46d --- /dev/null +++ b/internal/loki/sys_childbind_linux.go @@ -0,0 +1,20 @@ +//go:build linux + +package loki + +// Lier le Chrome du computer use à la vie de Loki (repris d'AJEAN) : le noyau +// lui envoie SIGKILL quand Loki meurt, même tué brutalement. Sans ça, un Chrome +// orphelin restait dans le conteneur après un redémarrage de l'UI. Ses +// sous-processus suivent la mort du processus principal de Chrome. + +import ( + "os/exec" + "syscall" +) + +func prepareBoundChild(cmd *exec.Cmd) { + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL +} diff --git a/internal/loki/sys_childbind_other.go b/internal/loki/sys_childbind_other.go new file mode 100644 index 0000000..c4f630d --- /dev/null +++ b/internal/loki/sys_childbind_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package loki + +import "os/exec" + +// Hors Linux, pas d'équivalent simple à Pdeathsig : le navigateur est fermé à +// l'arrêt propre de Loki. (AJEAN passe par un job object sous Windows.) +func prepareBoundChild(cmd *exec.Cmd) {} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index abb3b27..eb7ec46 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -2305,6 +2305,20 @@ html[data-files="1"] #files-btn{color:var(--accent)} transition:background .15s,color .15s,border-color .15s} #chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)} #chat .history-more button:disabled{opacity:.6;cursor:default} +/* Aperçu en direct du navigateur piloté (09-stream.js showCuShot, repris d'AJEAN). */ +.cu-shot{flex-shrink:0;align-self:flex-start;width:min(640px,100%);margin:2px 0 6px;overflow:hidden; + transition:height .35s cubic-bezier(.2,.7,.3,1),opacity .3s ease,margin .35s ease;animation:cushotcard .3s ease both} +.cu-shot.out{opacity:0;margin:0} +@keyframes cushotcard{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} +.cu-shot-cap{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--dim);margin:0 0 6px 2px;min-width:0} +.cu-shot-cap svg{flex:none} +.cu-shot-t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.cu-shot-img{position:relative;border-radius:12px;overflow:hidden;background:var(--row-bg); + box-shadow:0 4px 18px rgba(0,0,0,.10);aspect-ratio:16 / 10} +.cu-shot-img img.cu-frame{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top; + opacity:0;transition:opacity .45s ease;cursor:zoom-in} +.cu-shot-img img.cu-frame.in{opacity:1} +@media (prefers-reduced-motion:reduce){.cu-shot,.cu-shot-img img.cu-frame{transition:none;animation:none}}