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.
This commit is contained in:
Fini 2026-07-13 21:41:41 +08:00
parent c5feaeec01
commit f1178c76c1
2 changed files with 80 additions and 4 deletions

View file

@ -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<Mutex<HashSet<String>>>,
/// 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<Mutex<HashMap<String, String>>>,
}
/// 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<OpenverseCredentials>,
used_urls: Arc<Mutex<HashSet<String>>>,
resolved: Arc<Mutex<HashMap<String, String>>>,
) -> 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 }
}

View file

@ -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<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
let resolved: Arc<Mutex<HashMap<String, String>>> = 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"
);
}