2026-10-13 00:58:57 +02:00
|
|
|
use super::pipeline::BuildWork;
|
2026-10-12 19:16:23 +02:00
|
|
|
use super::*;
|
|
|
|
|
use searchi_media::*;
|
2026-10-13 00:58:57 +02:00
|
|
|
use store::SourceTask;
|
2026-10-12 19:16:23 +02:00
|
|
|
|
|
|
|
|
#[derive(Debug)]
|
|
|
|
|
struct InferenceFailure(anyhow::Error);
|
|
|
|
|
impl std::fmt::Display for InferenceFailure {
|
|
|
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
|
|
|
write!(f, "Media inference failed: {}", self.0)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
impl std::error::Error for InferenceFailure {}
|
|
|
|
|
|
|
|
|
|
impl HybridSearch {
|
|
|
|
|
/// Publish text and actual image/frame/audio vectors in one atomic generation.
|
|
|
|
|
pub fn index_embeddings_with_media(
|
|
|
|
|
&mut self,
|
|
|
|
|
tools: &FfmpegTools,
|
|
|
|
|
limits: &MediaLimits,
|
|
|
|
|
) -> Result<EmbeddingReport> {
|
2026-10-12 23:54:26 +02:00
|
|
|
let mut fingerprint = generation_fingerprint(self.embedder.fingerprint());
|
2026-10-12 19:16:23 +02:00
|
|
|
let defaults = MediaLimits::default();
|
|
|
|
|
if limits.max_frames != defaults.max_frames
|
|
|
|
|
|| limits.frame_interval_ms != defaults.frame_interval_ms
|
|
|
|
|
|| limits.max_scene_frames != defaults.max_scene_frames
|
|
|
|
|
|| limits.audio_window_ms != defaults.audio_window_ms
|
|
|
|
|
|| limits.audio_overlap_ms != defaults.audio_overlap_ms
|
|
|
|
|
|| limits.max_audio_windows != defaults.max_audio_windows
|
|
|
|
|
|| limits.max_duration_ms != defaults.max_duration_ms
|
|
|
|
|
|| limits.max_file_bytes != defaults.max_file_bytes
|
|
|
|
|
{
|
2026-10-12 23:54:26 +02:00
|
|
|
fingerprint.push_str(&format!(
|
2026-10-12 19:16:23 +02:00
|
|
|
":custom-{}-{}-{}-{}-{}-{}-{}-{}",
|
|
|
|
|
limits.max_frames,
|
|
|
|
|
limits.frame_interval_ms,
|
|
|
|
|
limits.max_scene_frames,
|
|
|
|
|
limits.audio_window_ms,
|
|
|
|
|
limits.audio_overlap_ms,
|
|
|
|
|
limits.max_audio_windows,
|
|
|
|
|
limits.max_duration_ms,
|
|
|
|
|
limits.max_file_bytes
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-10-13 00:58:57 +02:00
|
|
|
self.rebuild_generation(fingerprint, |service, generation, work| {
|
|
|
|
|
service.build(generation, work)?;
|
2026-10-12 19:16:23 +02:00
|
|
|
let mut after = 0;
|
|
|
|
|
let mut media_count = 0usize;
|
|
|
|
|
loop {
|
2026-10-12 23:54:26 +02:00
|
|
|
let files = service.store.media_batch(after)?;
|
2026-10-12 19:16:23 +02:00
|
|
|
if files.is_empty() {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
for (id, path, _, _) in files {
|
|
|
|
|
after = id;
|
|
|
|
|
let path = Path::new(&path);
|
|
|
|
|
let Some(kind) = classify(path) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
media_count += 1;
|
|
|
|
|
ensure!(
|
|
|
|
|
media_count <= 10000,
|
|
|
|
|
"Media generation exceeds 10000-file resource budget"
|
|
|
|
|
);
|
2026-10-13 00:58:57 +02:00
|
|
|
let before_chunks = work.report.chunks;
|
|
|
|
|
let identity = MediaIdentity::read(
|
2026-10-12 19:16:23 +02:00
|
|
|
path,
|
2026-10-13 00:58:57 +02:00
|
|
|
if kind == MediaKind::Image {
|
|
|
|
|
64 * 1024 * 1024
|
|
|
|
|
} else {
|
|
|
|
|
limits.max_file_bytes
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
let task = SourceTask {
|
|
|
|
|
key: super::pipeline::task_key(&["media", &path.to_string_lossy()]),
|
|
|
|
|
path: path.to_string_lossy().into_owned(),
|
|
|
|
|
source_type: "media".into(),
|
|
|
|
|
source_hash: identity
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|i| i.sha256.clone())
|
|
|
|
|
.unwrap_or_default(),
|
|
|
|
|
title_hash: String::new(),
|
|
|
|
|
policy: service.generation_fingerprint.clone(),
|
|
|
|
|
passage_id: -id,
|
|
|
|
|
};
|
|
|
|
|
work.track(&task)?;
|
|
|
|
|
service.store.stage_task(generation, &task)?;
|
|
|
|
|
let result = identity.and_then(|identity| {
|
|
|
|
|
service.build_media_source(
|
|
|
|
|
generation, id, path, kind, tools, limits, &identity, &task, work,
|
|
|
|
|
)?;
|
|
|
|
|
work.media.push(identity);
|
|
|
|
|
Ok(())
|
|
|
|
|
});
|
|
|
|
|
match result {
|
|
|
|
|
Ok(()) => work.report.media_files += 1,
|
2026-10-12 19:16:23 +02:00
|
|
|
Err(error) => {
|
2026-10-13 00:58:57 +02:00
|
|
|
if let Err(cleanup) =
|
|
|
|
|
service
|
|
|
|
|
.store
|
|
|
|
|
.fail_source(generation, &task, &format!("{error:#}"))
|
|
|
|
|
{
|
|
|
|
|
return Err(error.context(format!(
|
|
|
|
|
"Failed to discard incomplete media source: {cleanup:#}"
|
|
|
|
|
)));
|
|
|
|
|
}
|
2026-10-12 19:16:23 +02:00
|
|
|
if error.downcast_ref::<InferenceFailure>().is_some() {
|
|
|
|
|
return Err(error);
|
|
|
|
|
}
|
2026-10-12 23:54:26 +02:00
|
|
|
service.store.media_failure(
|
2026-10-12 19:16:23 +02:00
|
|
|
generation,
|
2026-10-13 00:58:57 +02:00
|
|
|
&task.path,
|
2026-10-12 19:16:23 +02:00
|
|
|
&format!("{error:#}"),
|
|
|
|
|
)?;
|
2026-10-13 00:58:57 +02:00
|
|
|
work.report.chunks = before_chunks;
|
|
|
|
|
if work.report.failures.len() < 1000 {
|
|
|
|
|
work.report.failures.push(MediaFailure {
|
|
|
|
|
path: task.path,
|
2026-10-12 19:16:23 +02:00
|
|
|
message: format!("{error:#}").chars().take(2048).collect(),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-10-13 00:58:57 +02:00
|
|
|
work.report.media_failures += 1;
|
2026-10-12 19:16:23 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-13 00:58:57 +02:00
|
|
|
Ok(())
|
2026-10-12 23:54:26 +02:00
|
|
|
})
|
2026-10-12 19:16:23 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-13 00:58:57 +02:00
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
|
|
|
fn build_media_source(
|
|
|
|
|
&mut self,
|
|
|
|
|
generation: i64,
|
|
|
|
|
id: i64,
|
|
|
|
|
path: &Path,
|
|
|
|
|
kind: MediaKind,
|
|
|
|
|
tools: &FfmpegTools,
|
|
|
|
|
limits: &MediaLimits,
|
|
|
|
|
identity: &MediaIdentity,
|
|
|
|
|
task: &SourceTask,
|
|
|
|
|
work: &mut BuildWork,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
if let Some(count) = self
|
|
|
|
|
.store
|
|
|
|
|
.reuse_media_source(generation, task, id, identity)?
|
|
|
|
|
{
|
|
|
|
|
ensure!(
|
|
|
|
|
work.report.chunks + count <= MAX_SCAN_CHUNKS,
|
|
|
|
|
"Media exceeds generation chunk budget"
|
|
|
|
|
);
|
|
|
|
|
if kind != MediaKind::Image {
|
|
|
|
|
// Metadata remains part of music search after path remapping.
|
|
|
|
|
let metadata = probe(tools, path, limits)?;
|
|
|
|
|
self.refresh_media_metadata(path, identity, &metadata)?;
|
|
|
|
|
}
|
|
|
|
|
identity.ensure_unchanged()?;
|
|
|
|
|
work.report.chunks += count;
|
|
|
|
|
work.report.reused_chunks += count;
|
|
|
|
|
work.report.reused_sources += 1;
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
let before = work.report.chunks;
|
|
|
|
|
self.build_media_file(
|
|
|
|
|
generation,
|
|
|
|
|
id,
|
|
|
|
|
path,
|
|
|
|
|
kind,
|
|
|
|
|
tools,
|
|
|
|
|
limits,
|
|
|
|
|
identity,
|
|
|
|
|
&mut work.report,
|
|
|
|
|
)?;
|
|
|
|
|
self.store
|
|
|
|
|
.complete_source(generation, task, work.report.chunks - before)?;
|
|
|
|
|
work.report.encoded_sources += 1;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn refresh_media_metadata(
|
|
|
|
|
&mut self,
|
|
|
|
|
path: &Path,
|
|
|
|
|
identity: &MediaIdentity,
|
|
|
|
|
metadata: &MediaProbe,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let tags = metadata
|
|
|
|
|
.tags
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|(k, v)| format!("{k}: {v}"))
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("\n");
|
|
|
|
|
self.lexical.set_media_metadata(
|
|
|
|
|
&path.to_string_lossy(),
|
|
|
|
|
&tags,
|
|
|
|
|
metadata.duration_ms,
|
|
|
|
|
identity.bytes,
|
|
|
|
|
identity.modified_ns,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-12 19:16:23 +02:00
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
|
|
|
fn build_media_file(
|
|
|
|
|
&mut self,
|
|
|
|
|
generation: i64,
|
|
|
|
|
id: i64,
|
|
|
|
|
path: &Path,
|
|
|
|
|
kind: MediaKind,
|
|
|
|
|
tools: &FfmpegTools,
|
|
|
|
|
limits: &MediaLimits,
|
2026-10-13 00:58:57 +02:00
|
|
|
identity: &MediaIdentity,
|
2026-10-12 19:16:23 +02:00
|
|
|
report: &mut EmbeddingReport,
|
|
|
|
|
) -> Result<()> {
|
|
|
|
|
let mut ordinal = 0;
|
|
|
|
|
if kind == MediaKind::Image {
|
|
|
|
|
validate_image(path)?;
|
|
|
|
|
ensure!(
|
|
|
|
|
report.chunks < MAX_SCAN_CHUNKS,
|
|
|
|
|
"Media exceeds generation chunk budget"
|
|
|
|
|
);
|
2026-10-13 00:58:57 +02:00
|
|
|
report.image_inference_calls += 1;
|
2026-10-12 19:16:23 +02:00
|
|
|
let vectors = self
|
|
|
|
|
.embedder
|
|
|
|
|
.embed_images(&[path], 280)
|
|
|
|
|
.map_err(|e| anyhow::Error::new(InferenceFailure(e)))?;
|
|
|
|
|
ensure!(vectors.len() == 1, "Image embedding count mismatch");
|
|
|
|
|
self.store
|
2026-10-13 00:58:57 +02:00
|
|
|
.insert_media(generation, id, ordinal, identity, "image", 0, &vectors[0])?;
|
2026-10-12 19:16:23 +02:00
|
|
|
report.chunks += 1;
|
|
|
|
|
} else {
|
|
|
|
|
let metadata = probe(tools, path, limits)?;
|
2026-10-13 00:58:57 +02:00
|
|
|
self.refresh_media_metadata(path, identity, &metadata)?;
|
2026-10-12 19:16:23 +02:00
|
|
|
if metadata.has_video && kind == MediaKind::Video {
|
|
|
|
|
let mut seen = std::collections::HashSet::new();
|
|
|
|
|
let mut scenes = 0;
|
|
|
|
|
for time in frame_times(&metadata, limits)? {
|
|
|
|
|
let frames = decode_frames(
|
|
|
|
|
tools,
|
|
|
|
|
path,
|
|
|
|
|
&metadata,
|
|
|
|
|
time,
|
|
|
|
|
scenes < limits.max_scene_frames,
|
|
|
|
|
limits,
|
|
|
|
|
)?;
|
|
|
|
|
for (i, frame) in frames.frames.iter().enumerate() {
|
|
|
|
|
if !seen.insert(frame.timestamp_ms) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if i > 0 {
|
|
|
|
|
scenes += 1;
|
|
|
|
|
}
|
|
|
|
|
ensure!(
|
|
|
|
|
report.chunks < MAX_SCAN_CHUNKS,
|
|
|
|
|
"Media exceeds generation chunk budget"
|
|
|
|
|
);
|
2026-10-13 00:58:57 +02:00
|
|
|
report.image_inference_calls += 1;
|
2026-10-12 19:16:23 +02:00
|
|
|
let vectors = self
|
|
|
|
|
.embedder
|
|
|
|
|
.embed_images(&[frame.path.as_path()], 70)
|
|
|
|
|
.map_err(|e| anyhow::Error::new(InferenceFailure(e)))?;
|
|
|
|
|
ensure!(vectors.len() == 1, "Frame embedding count mismatch");
|
|
|
|
|
self.store.insert_media(
|
|
|
|
|
generation,
|
|
|
|
|
id,
|
|
|
|
|
ordinal,
|
2026-10-13 00:58:57 +02:00
|
|
|
identity,
|
2026-10-12 19:16:23 +02:00
|
|
|
"video",
|
|
|
|
|
frame.timestamp_ms,
|
|
|
|
|
&vectors[0],
|
|
|
|
|
)?;
|
|
|
|
|
ordinal += 1;
|
|
|
|
|
report.chunks += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if metadata.has_audio {
|
|
|
|
|
for window in audio_windows(&metadata, limits)? {
|
|
|
|
|
ensure!(
|
|
|
|
|
report.chunks < MAX_SCAN_CHUNKS,
|
|
|
|
|
"Audio exceeds generation chunk budget"
|
|
|
|
|
);
|
|
|
|
|
let pcm = decode_audio(tools, path, window, limits)?;
|
2026-10-13 00:58:57 +02:00
|
|
|
report.audio_inference_calls += 1;
|
2026-10-12 19:16:23 +02:00
|
|
|
let vector = self
|
|
|
|
|
.embedder
|
|
|
|
|
.embed_audio(&pcm)
|
|
|
|
|
.map_err(|e| anyhow::Error::new(InferenceFailure(e)))?;
|
|
|
|
|
self.store.insert_media(
|
|
|
|
|
generation,
|
|
|
|
|
id,
|
|
|
|
|
ordinal,
|
2026-10-13 00:58:57 +02:00
|
|
|
identity,
|
2026-10-12 19:16:23 +02:00
|
|
|
"audio",
|
|
|
|
|
window.start_ms,
|
|
|
|
|
&vector,
|
|
|
|
|
)?;
|
|
|
|
|
ordinal += 1;
|
|
|
|
|
report.chunks += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
identity.ensure_unchanged()?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|