From f1178c76c1073d348ced71222a0999093c27dc8d Mon Sep 17 00:00:00 2001 From: Fini Date: Mon, 13 Jul 2026 21:41:41 +0800 Subject: [PATCH] fix(desktop): one subject, one photo - a rebuilt card keeps its picture Watching a run frame by frame: a real Bali temple photo landed, then halfway through it turned into a plain blue sky. The model had rebuilt the section (fresh node ids), the same query searched again, and the session-wide dedup - there to stop two cards sharing one picture - now skipped the very photo that query had already chosen, leaving the junk below it. A query this session already answered now resolves from a memo: no network call, and no downgrade. Dedup still guards different subjects from sharing a photo. --- .../src/image_search_session.rs | 42 +++++++++++++++++-- .../src/image_search_session_tests.rs | 42 +++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/crates/op-host-desktop/src/image_search_session.rs b/crates/op-host-desktop/src/image_search_session.rs index f56a15bf1..a1d95e2c2 100644 --- a/crates/op-host-desktop/src/image_search_session.rs +++ b/crates/op-host-desktop/src/image_search_session.rs @@ -1,6 +1,6 @@ //! Background image-search enrichment for generated image nodes. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -229,6 +229,21 @@ pub(crate) struct ImageSearchSession { /// their Openverse top hit, which filled three different cards with the /// SAME photo (measured: test0711-22). Selection skips these best-effort. used_urls: Arc>>, + /// Query → the photo that query already resolved to, this session. + /// + /// The dedup above must NOT fire when the SAME subject comes back: the + /// model rebuilds a section mid-run (fresh node ids), the same query + /// ("Bali Indonesia") searches again, and its own good photo is now in + /// `used_urls` — so the second search skipped it and took a junk result + /// instead (measured 2026-07-12: a real Bali temple photo turned into a + /// plain blue sky halfway through a run). One subject, one photo: a repeat + /// query resolves from this memo with no network call at all. + resolved: Arc>>, +} + +/// Memo key — the subject, not its spelling. +fn query_key(query: &str) -> String { + query.trim().to_ascii_lowercase() } impl ImageSearchSession { @@ -254,6 +269,7 @@ impl ImageSearchSession { self.jobs.clear(); self.invalidate_scan_gate(); self.used_urls.lock().unwrap().clear(); + self.resolved.lock().unwrap().clear(); } pub(crate) fn is_pending(&self) -> bool { @@ -312,7 +328,12 @@ impl ImageSearchSession { self.in_flight.insert(id); let job = match &gen_profile { Some(profile) => spawn_gen_job(target, profile.clone()), - None => spawn_job(target, credentials.clone(), Arc::clone(&self.used_urls)), + None => spawn_job( + target, + credentials.clone(), + Arc::clone(&self.used_urls), + Arc::clone(&self.resolved), + ), }; self.jobs.push(job); } @@ -370,17 +391,30 @@ fn spawn_job( target: ImageSearchTarget, credentials: Option, used_urls: Arc>>, + resolved: Arc>>, ) -> ImageSearchJob { let (tx, rx) = mpsc::channel(); let node_id = target.node_id.clone(); let aspect_ratio = target.aspect_ratio; + let key = query_key(&target.query); + // One subject, one photo: a query this session already answered resolves + // from the memo — no network, and (crucially) no dedup-forced downgrade + // when a rebuilt section asks for the same picture again. + if let Some(url) = resolved.lock().unwrap().get(&key).cloned() { + let _ = tx.send(Some(url)); + return ImageSearchJob { node_id, rx }; + } std::thread::spawn(move || { - let _ = tx.send(fetch_first_image_url_blocking( + let url = fetch_first_image_url_blocking( &target.query, aspect_ratio, credentials.as_ref(), &used_urls, - )); + ); + if let Some(found) = url.as_ref() { + resolved.lock().unwrap().insert(key, found.clone()); + } + let _ = tx.send(url); }); ImageSearchJob { node_id, rx } } diff --git a/crates/op-host-desktop/src/image_search_session_tests.rs b/crates/op-host-desktop/src/image_search_session_tests.rs index ce5a5e1ae..0d10b1acb 100644 --- a/crates/op-host-desktop/src/image_search_session_tests.rs +++ b/crates/op-host-desktop/src/image_search_session_tests.rs @@ -1015,3 +1015,45 @@ fn anonymous_cover_slot_uses_sibling_text_as_query() { slot.query ); } + +/// The measured churn: the model rebuilds a section mid-run, the same subject +/// searches again, and the session-wide dedup skips the very photo it picked +/// the first time — so a real Bali temple photo became a plain blue sky. One +/// subject, one photo: a repeat query resolves from the memo, and the dedup +/// only ever guards DIFFERENT subjects from sharing a picture. +#[test] +fn a_repeat_query_gets_the_same_photo_back_not_a_dedup_downgrade() { + use super::{query_key, spawn_job, ImageSearchTarget}; + use std::collections::{HashMap, HashSet}; + use std::sync::{Arc, Mutex}; + + let used_urls: Arc>> = Arc::new(Mutex::new(HashSet::new())); + let resolved: Arc>> = Arc::new(Mutex::new(HashMap::new())); + // The first search already answered "Bali Indonesia" and marked its photo used. + let good = "https://example.org/bali-temple.jpg".to_string(); + resolved + .lock() + .unwrap() + .insert(query_key("Bali Indonesia"), good.clone()); + used_urls.lock().unwrap().insert(good.clone()); + + // The rebuilt card asks again — differently spelled, same subject. + let target = ImageSearchTarget { + node_id: op_editor_core::NodeId::new("n99".to_string()), + query: " bali indonesia ".to_string(), + prompt: None, + aspect_ratio: None, + width: None, + height: None, + }; + let job = spawn_job(target, None, Arc::clone(&used_urls), Arc::clone(&resolved)); + let answer = job + .rx + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("the memo answers without touching the network"); + assert_eq!( + answer, + Some(good), + "the rebuilt card gets ITS photo back, not the next-best junk result" + ); +}