faces: filter videos out of detection candidate set

The backlog drain pulls every hashed image_exif row, which includes videos.
Sending them to Apollo just produces 422 decode_failed → status='failed'
markers, burning a round-trip per video and inflating the FAILED stat.

Widen filter_excluded to also drop anything is_image_file rejects. Covers
both call sites (file-watch hook and per-tick backlog drain) without
plumbing a second filter through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cameron Cordes
2026-04-30 12:45:55 +00:00
parent 1971eeccd6
commit 5e1bad3179

View File

@@ -415,22 +415,36 @@ fn try_auto_bind(
/// Pulled out for unit testing — the same `PathExcluder` /memories uses,
/// just applied at the face-detect candidate set instead of the memories
/// listing. Skip @eaDir / .thumbnails / user-defined paths before we burn
/// a detect call (and Apollo's GPU memory) on junk.
/// a detect call (and Apollo's GPU memory) on junk. Also drops anything
/// that isn't an image file — the backlog drain pulls every hashed row in
/// `image_exif`, which includes videos; sending those to Apollo just
/// produces `failed` markers and inflates the FAILED stat.
pub(crate) fn filter_excluded(
base: &Path,
excluded_dirs: &[String],
candidates: Vec<FaceCandidate>,
library_name: Option<&str>,
) -> Vec<FaceCandidate> {
if excluded_dirs.is_empty() {
return candidates;
}
let excluder = PathExcluder::new(base, excluded_dirs);
let excluder = if excluded_dirs.is_empty() {
None
} else {
Some(PathExcluder::new(base, excluded_dirs))
};
candidates
.into_iter()
.filter(|c| {
let abs = base.join(&c.rel_path);
if excluder.is_excluded(&abs) {
if !file_types::is_image_file(&abs) {
debug!(
"face_watch: skipping non-image path {} (library {})",
c.rel_path,
library_name.unwrap_or("<unknown>")
);
return false;
}
if let Some(ex) = excluder.as_ref()
&& ex.is_excluded(&abs)
{
debug!(
"face_watch: skipping excluded path {} (library {})",
c.rel_path,
@@ -507,8 +521,8 @@ mod tests {
#[test]
fn filter_excluded_empty_rules_passes_all() {
// Skip the PathExcluder build entirely on the common path where
// EXCLUDED_DIRS is unset — saves an allocation per pass.
// EXCLUDED_DIRS unset still lets every image through — only the
// PathExcluder is skipped, the image-extension gate still runs.
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let candidates = vec![cand("a.jpg"), cand("b.jpg")];
@@ -516,6 +530,25 @@ mod tests {
assert_eq!(kept.len(), 2);
}
#[test]
fn filter_excluded_drops_videos_and_non_media() {
// Backlog drain pulls every hashed row in image_exif (videos
// included). Videos must never reach Apollo — opencv can't
// decode them, every call would 422 and write a `failed` marker.
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path();
let candidates = vec![
cand("photos/a.jpg"),
cand("photos/clip.mp4"),
cand("photos/clip.MOV"),
cand("photos/notes.txt"),
cand("photos/b.heic"),
];
let kept = filter_excluded(base, &[], candidates, Some("test"));
let kept_paths: Vec<_> = kept.iter().map(|c| c.rel_path.as_str()).collect();
assert_eq!(kept_paths, vec!["photos/a.jpg", "photos/b.heic"]);
}
#[test]
fn read_bytes_passes_through_for_jpeg() {
// JPEG goes through plain read — we DON'T want to lose orientation