feat: add multimodal search and granular performance diagnostics
Core CI / core (ubuntu-latest) (push) Failing after 3m31s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m39s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
Core CI / core (ubuntu-latest) (push) Failing after 3m31s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m39s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
This commit is contained in:
1 parent
d3bb8c7618
commit
b8400c4fbf
110 files changed
+79395
-250
No files matched your search
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
target/
|
||||
_*.py
|
||||
__pycache__/
|
||||
Generated
+1311
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "searchi-media-eval"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
searchi-semantic = { path = "../../crates/searchi-semantic" }
|
||||
searchi-core = { path = "../../crates/searchi-core" }
|
||||
searchi-service = { path = "../../crates/searchi-service" }
|
||||
searchi-media = { path = "../../crates/searchi-media" }
|
||||
tempfile = "3"
|
||||
@@ -0,0 +1,35 @@
|
||||
# Real-media quality evaluation
|
||||
|
||||
This independent tool owns no production/UI code. It feeds real pixels and decoded PCM to the actual Google CPU worker, keeps frozen source labels outside inference input, and reports ranking/localization failures.
|
||||
|
||||
```powershell
|
||||
$env:SEARCHI_FORCE_CPU = '1'
|
||||
python tools/media-eval/download.py
|
||||
python tools/media-eval/prepare.py --ffmpeg .searchi/media-runtime/bin/ffmpeg.exe --ffprobe .searchi/media-runtime/bin/ffprobe.exe
|
||||
cargo run --manifest-path tools/media-eval/Cargo.toml --bin searchi-media-eval -- fixtures/media .searchi/models/embeddinggemma2 .searchi/runtime/cpu/Release/searchi-gemma-worker.exe fixtures/media/reference-results.json
|
||||
python tools/media-eval/score.py fixtures/media/reference-results.json fixtures/media/reference-summary.json
|
||||
cargo run --manifest-path tools/media-eval/Cargo.toml --bin metadata -- fixtures/media .searchi/media-runtime/bin/ffmpeg.exe .searchi/media-runtime/bin/ffprobe.exe fixtures/media/metadata-results.json
|
||||
python tools/media-eval/score.py fixtures/media/metadata-results.json fixtures/media/metadata-summary.json
|
||||
cargo run --manifest-path tools/media-eval/Cargo.toml --bin pipeline -- fixtures/media .searchi/models/embeddinggemma2 .searchi/runtime/cpu/Release/searchi-gemma-worker.exe .searchi/media-runtime/bin/ffmpeg.exe .searchi/media-runtime/bin/ffprobe.exe fixtures/media/pipeline-30s-results.json 30
|
||||
cargo run --manifest-path tools/media-eval/Cargo.toml --bin pipeline -- fixtures/media .searchi/models/embeddinggemma2 .searchi/runtime/cpu/Release/searchi-gemma-worker.exe .searchi/media-runtime/bin/ffmpeg.exe .searchi/media-runtime/bin/ffprobe.exe fixtures/media/pipeline-10s-results.json 10
|
||||
python tools/media-eval/score.py fixtures/media/pipeline-30s-results.json fixtures/media/pipeline-30s-summary.json
|
||||
python tools/media-eval/score.py fixtures/media/pipeline-10s-results.json fixtures/media/pipeline-10s-summary.json
|
||||
cargo run --manifest-path tools/media-eval/Cargo.toml --bin image_budgets -- fixtures/media .searchi/models/embeddinggemma2 .searchi/runtime/cpu/Release/searchi-gemma-worker.exe fixtures/media/image-budget-results.json
|
||||
python tools/media-eval/score_budgets.py fixtures/media/image-budget-results.json fixtures/media/image-budget-summary.json
|
||||
python -m unittest discover -s tools/media-eval -p test_*.py
|
||||
```
|
||||
|
||||
The standalone Cargo workspace keeps evaluation dependencies separate. Native inference and FFmpeg decoding use CPU only and one thread. No test enables a GPU provider. The model/worker/decoder paths above refer to explicitly provisioned local developer artifacts; they are not end-user setup requirements.
|
||||
|
||||
The dense reference samples video every two seconds and PCM in thirty-second windows. Photos use 140 image tokens and frames 70. The separate comparison fixes the same 12 photos/24 queries and varies only photo token budgets **70, 280, 1120**. It does not change labels or choose a threshold after observing scores.
|
||||
|
||||
## Interpretation
|
||||
|
||||
- Recall@1/@5 and MRR rank distinct source files, choosing each file's best segment. Per-category/per-language metrics remain separate.
|
||||
- Segment overlap credits a returned segment that actually intersects the labelled event in the correct file. Point hits require the returned start timestamp itself to fall inside the event. A thirty-second speech window may pass overlap while opening far too early; both metrics are required.
|
||||
- No-answer queries remain separate from positive recall. The tool exposes returned neighbors and scores without inventing an abstention threshold. A reject threshold needs independent calibration and held-out validation.
|
||||
- Direct model candidate filters are **reference pool constraints**, not evidence that production filters work. Artist/title identity cannot be recovered reliably from a performance embedding; exact metadata rows are explicitly unsupported until evaluated through Searchi's real lexical metadata lane.
|
||||
- `metadata` tests the actual FFprobe/guarded core FTS lane; `pipeline` tests the actual unified Searchi media generation and hybrid ranking. The optional final argument varies audio window seconds (30 versus 10) while retaining two-second overlap, the same real files, decoder and frozen labels. It does not change production defaults.
|
||||
- Model agreement with another implementation, synthetic document-seed scores, and this small-media benchmark each answer different questions. None is production quality certification.
|
||||
|
||||
Detailed provenance is in [the fixture README](../../fixtures/media/README.md). The final assessment belongs in [MEDIA-QUALITY.md](../../docs/MEDIA-QUALITY.md).
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Reacquire the locked licensed corpus. Updated bytes are never accepted."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", type=Path, default=Path("fixtures/media"))
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
fixture_path = root / "fixture.json"
|
||||
lock = json.loads((root / "labels.lock.json").read_text())
|
||||
assert hashlib.sha256(fixture_path.read_bytes()).hexdigest() == lock["fixture_sha256"]
|
||||
for asset in json.loads(fixture_path.read_text(encoding="utf-8"))["assets"]:
|
||||
destination = (root / asset["path"]).resolve()
|
||||
assert destination.is_relative_to(root)
|
||||
if destination.is_file() and hashlib.sha256(destination.read_bytes()).hexdigest() == asset["sha256"]:
|
||||
continue
|
||||
request = urllib.request.Request(asset["source_url"], headers={"User-Agent": "SearchiMediaEvaluation/1.0"})
|
||||
with urllib.request.urlopen(request, timeout=60) as response:
|
||||
data = response.read(asset["bytes"] + 1)
|
||||
assert len(data) == asset["bytes"] and hashlib.sha256(data).hexdigest() == asset["sha256"], f"Publisher changed {asset['id']}; keep frozen corpus, investigate a new version"
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(data)
|
||||
print("Verified", asset["id"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Prepare real CPU-decoded media candidates after verifying frozen labels.
|
||||
|
||||
No generated captions, placeholder vectors, or model calls occur in preparation.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
def verify(root):
|
||||
fixture_path = root / "fixture.json"
|
||||
lock = json.loads((root / "labels.lock.json").read_text())
|
||||
assert hashlib.sha256(fixture_path.read_bytes()).hexdigest() == lock["fixture_sha256"], "Frozen labels changed"
|
||||
fixture = json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||
for asset in fixture["assets"]:
|
||||
path = (root / asset["path"]).resolve()
|
||||
assert path.is_relative_to(root.resolve()), "Asset escaped corpus root"
|
||||
assert path.stat().st_size == asset["bytes"], asset["id"]
|
||||
assert hashlib.sha256(path.read_bytes()).hexdigest() == asset["sha256"], asset["id"]
|
||||
return fixture
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", type=Path, default=Path("fixtures/media"))
|
||||
parser.add_argument("--ffmpeg", type=Path, required=True)
|
||||
parser.add_argument("--ffprobe", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
root = args.root.resolve()
|
||||
fixture = verify(root)
|
||||
derived = root / "derived"
|
||||
derived.mkdir(exist_ok=True)
|
||||
candidates = []
|
||||
for asset in fixture["assets"]:
|
||||
source = root / asset["path"]
|
||||
if asset["kind"] == "image":
|
||||
candidates.append(dict(asset=asset["id"], path=str(source), modality="image", token_budget=140))
|
||||
continue
|
||||
probe = json.loads(subprocess.check_output([str(args.ffprobe), "-v", "error", "-show_format", "-show_streams", "-of", "json", str(source)], text=True))
|
||||
duration = float(probe["format"]["duration"])
|
||||
if asset["kind"] == "video":
|
||||
for stamp in range(0, int(duration) + 1, 2):
|
||||
output = derived / f"{asset['id']}-frame-{stamp:04}.png"
|
||||
subprocess.run([str(args.ffmpeg), "-hide_banner", "-loglevel", "error", "-hwaccel", "none", "-threads", "1", "-ss", str(stamp), "-i", str(source), "-frames:v", "1", "-vf", "scale=480:-1", "-threads", "1", "-y", str(output)], check=True)
|
||||
if output.is_file():
|
||||
candidates.append(dict(asset=asset["id"], path=str(output), modality="image", token_budget=70, start_s=stamp, end_s=min(stamp + 2, duration)))
|
||||
if any(stream["codec_type"] == "audio" for stream in probe["streams"]):
|
||||
for stamp in range(0, int(duration) + 1, 30):
|
||||
output = derived / f"{asset['id']}-audio-{stamp:04}.f32"
|
||||
subprocess.run([str(args.ffmpeg), "-hide_banner", "-loglevel", "error", "-hwaccel", "none", "-threads", "1", "-ss", str(stamp), "-i", str(source), "-t", "30", "-vn", "-ac", "1", "-ar", "16000", "-f", "f32le", "-threads", "1", "-y", str(output)], check=True)
|
||||
if output.stat().st_size:
|
||||
assert output.stat().st_size <= 480000 * 4, "Audio exceeds worker budget"
|
||||
candidates.append(dict(asset=asset["id"], path=str(output), modality="audio", start_s=stamp, end_s=min(stamp + 30, duration)))
|
||||
manifest = dict(fixture_sha256=hashlib.sha256((root / "fixture.json").read_bytes()).hexdigest(), ffmpeg_version=subprocess.check_output([str(args.ffmpeg), "-version"], text=True).splitlines()[0], policy="CPU-only dense reference: photos140/video70 tokens, uniform2s frames, mono16kHz30s PCM", candidates=candidates)
|
||||
(derived / "candidates.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
||||
print(json.dumps(dict(candidates=len(candidates), images=sum(item["modality"] == "image" for item in candidates), audio=sum(item["modality"] == "audio" for item in candidates))))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Score frozen relevance without fitting an abstention threshold."""
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def temporal_hit(candidate, query, point=False):
|
||||
asset = candidate.get("asset")
|
||||
if asset not in query.get("expected", []):
|
||||
return False
|
||||
if query.get("required_modality") and candidate.get("modality") != query["required_modality"]:
|
||||
return False
|
||||
ranges = query.get("intervals", {}).get(asset, [])
|
||||
start = candidate.get("start_s")
|
||||
end = candidate.get("end_s", start)
|
||||
if start is None:
|
||||
return False
|
||||
if point:
|
||||
return any(left <= start <= right for left, right in ranges)
|
||||
return any(min(end, right) > max(start, left) for left, right in ranges)
|
||||
|
||||
|
||||
def timestamp_distance(candidate, query):
|
||||
"""Raw seconds from returned start to the nearest labelled event interval."""
|
||||
if candidate.get("asset") not in query.get("expected", []) or candidate.get("start_s") is None:
|
||||
return None
|
||||
if query.get("required_modality") and candidate.get("modality") != query["required_modality"]:
|
||||
return None
|
||||
ranges = query.get("intervals", {}).get(candidate["asset"], [])
|
||||
stamp = candidate["start_s"]
|
||||
return min((max(left - stamp, stamp - right, 0) for left, right in ranges), default=None)
|
||||
|
||||
|
||||
def metrics(rows):
|
||||
positive = [row for row in rows if row["query"]["expected"] and not row.get("unsupported")]
|
||||
negative = [row for row in rows if not row["query"]["expected"] and not row.get("unsupported")]
|
||||
temporal = [row for row in positive if row["query"]["category"].startswith("video-")]
|
||||
distances = sorted(distance for row in temporal if row["file_rankings"] and (distance := timestamp_distance(row["file_rankings"][0]["candidate"], row["query"])) is not None)
|
||||
ranks = []
|
||||
for row in positive:
|
||||
expected = row["query"]["expected"]
|
||||
ranks.append(next((i + 1 for i, hit in enumerate(row["file_rankings"]) if hit["candidate"]["asset"] in expected), None))
|
||||
fraction = lambda count, total: count / total if total else None
|
||||
return dict(
|
||||
positive_queries=len(positive),
|
||||
unsupported_queries=sum(bool(row.get("unsupported")) for row in rows),
|
||||
recall_at1=fraction(sum(rank == 1 for rank in ranks), len(ranks)),
|
||||
recall_at5=fraction(sum(rank is not None and rank <= 5 for rank in ranks), len(ranks)),
|
||||
mrr=fraction(sum(1 / rank if rank else 0 for rank in ranks), len(ranks)),
|
||||
temporal_queries=len(temporal),
|
||||
segment_overlap_hit_at1=fraction(sum(bool(row["file_rankings"]) and temporal_hit(row["file_rankings"][0]["candidate"], row["query"]) for row in temporal), len(temporal)),
|
||||
timestamp_point_hit_at1=fraction(sum(bool(row["file_rankings"]) and temporal_hit(row["file_rankings"][0]["candidate"], row["query"], point=True) for row in temporal), len(temporal)),
|
||||
segment_overlap_hit_at5=fraction(sum(any(temporal_hit(hit["candidate"], row["query"]) for hit in row["segment_top5"]) for row in temporal), len(temporal)),
|
||||
timestamp_distance_median_s=distances[len(distances) // 2] if distances else None,
|
||||
timestamp_distance_max_s=max(distances) if distances else None,
|
||||
no_answer_queries=len(negative),
|
||||
no_answer_return_rate=fraction(sum(bool(row["file_rankings"]) for row in negative), len(negative)),
|
||||
)
|
||||
|
||||
|
||||
def summarize(report):
|
||||
rows = report["rows"]
|
||||
by_category = defaultdict(list)
|
||||
by_language = defaultdict(list)
|
||||
failures = []
|
||||
for row in rows:
|
||||
query = row["query"]
|
||||
by_category[query["category"]].append(row)
|
||||
by_language[query["language"]].append(row)
|
||||
if row.get("unsupported"):
|
||||
failures.append(dict(query=query["id"], category="unsupported-query-capability", detail=row["unsupported"]))
|
||||
continue
|
||||
hits = row["file_rankings"]
|
||||
if not query["expected"]:
|
||||
if hits:
|
||||
failures.append(dict(query=query["id"], category="no-answer-nearest-neighbor-return", winner=hits[0]["candidate"]["asset"], score=hits[0]["score"]))
|
||||
elif not hits or hits[0]["candidate"]["asset"] not in query["expected"]:
|
||||
winner = hits[0]["candidate"]["asset"] if hits else None
|
||||
failures.append(dict(query=query["id"], category="hard-negative-confusion" if winner in query.get("hard_negatives", []) else "retrieval-top1-miss", winner=winner, expected=query["expected"]))
|
||||
elif query["category"].startswith("video-") and not temporal_hit(hits[0]["candidate"], query, point=True):
|
||||
candidate = hits[0]["candidate"]
|
||||
wrong_channel = query.get("required_modality") and candidate.get("modality") != query["required_modality"]
|
||||
failures.append(dict(query=query["id"], category="wrong-media-channel" if wrong_channel else "timestamp-localization-miss", candidate=candidate, expected_intervals=query["intervals"], timestamp_distance_s=timestamp_distance(candidate, query)))
|
||||
durations = sorted(row["query_ms"] for row in rows if "query_ms" in row)
|
||||
return dict(
|
||||
fixture_sha256=report["fixture_sha256"], worker=report["worker"],
|
||||
overall=metrics(rows), by_category={key: metrics(value) for key, value in sorted(by_category.items())},
|
||||
by_language={key: metrics(value) for key, value in sorted(by_language.items())},
|
||||
encoding_failures=report["encoding_failures"], encoding_ms=report["encoding_ms"],
|
||||
query_p50_ms=durations[len(durations) // 2] if durations else None,
|
||||
query_p95_ms=durations[min(len(durations) * 95 // 100, len(durations) - 1)] if durations else None,
|
||||
failures=failures,
|
||||
source_scope=report.get("scope", "Direct dense model baseline"),
|
||||
interpretation="Small independently labelled public-media diagnostic; source_scope identifies the measured lane. No-answer return rate measures unsuppressed output, not a calibrated confidence policy. Audio interval overlap can pass while the returned start timestamp is too early; point hits expose this. This corpus alone cannot certify production quality.",
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("report", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
args = parser.parse_args()
|
||||
report = json.loads(args.report.read_text(encoding="utf-8"))
|
||||
fixture = Path("fixtures/media/fixture.json")
|
||||
assert report["fixture_sha256"] == hashlib.sha256(fixture.read_bytes()).hexdigest()
|
||||
summary = summarize(report)
|
||||
args.output.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||||
print(json.dumps(summary["overall"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Summarize predeclared image-budget experiments without fitting thresholds."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from score import metrics
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("report", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
args = parser.parse_args()
|
||||
report = json.loads(args.report.read_text(encoding="utf-8"))
|
||||
fixture_hash = hashlib.sha256(Path("fixtures/media/fixture.json").read_bytes()).hexdigest()
|
||||
if report["fixture_sha256"] != fixture_hash:
|
||||
raise ValueError("Frozen fixture changed")
|
||||
if [budget["token_budget"] for budget in report["budgets"]] != [70, 280, 1120]:
|
||||
raise ValueError("Expected predeclared budgets in order")
|
||||
summary = {
|
||||
"fixture_sha256": fixture_hash,
|
||||
"worker": report["worker"],
|
||||
"photos": report["photos"],
|
||||
"queries": report["queries"],
|
||||
"query_embedding_ms": report["query_embedding_ms"],
|
||||
"budgets": [dict(token_budget=budget["token_budget"],
|
||||
encoding_ms=budget["encoding_ms"],
|
||||
metrics=metrics(budget["rows"]))
|
||||
for budget in report["budgets"]],
|
||||
"interpretation": "Same frozen 12 photos and 24 correlated EN/DE queries. Query embeddings shared across budgets. Encoding time is CPU batch cost; ranking-only time excludes inference. Equal small-corpus recall does not prove equal quality on larger or more difficult images.",
|
||||
}
|
||||
args.output.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
||||
print(json.dumps(summary["budgets"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Same frozen real photos/queries across predeclared token budgets.
|
||||
use anyhow::{ensure, Result};
|
||||
use searchi_semantic::{Device, GemmaEmbedder, GEMMA_MEDIA_FINGERPRINT};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs, path::PathBuf, time::Instant};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
ensure!(
|
||||
args.len() == 4,
|
||||
"Supply CORPUS_ROOT MODEL_DIR CPU_WORKER OUTPUT_JSON"
|
||||
);
|
||||
ensure!(
|
||||
std::env::var("SEARCHI_FORCE_CPU").as_deref() == Ok("1"),
|
||||
"CPU only"
|
||||
);
|
||||
let root = PathBuf::from(&args[0]);
|
||||
let bytes = fs::read(root.join("fixture.json"))?;
|
||||
let hash = format!("{:x}", Sha256::digest(&bytes));
|
||||
let lock: Value = serde_json::from_slice(&fs::read(root.join("labels.lock.json"))?)?;
|
||||
ensure!(lock["fixture_sha256"] == hash, "Frozen labels changed");
|
||||
let fixture: Value = serde_json::from_slice(&bytes)?;
|
||||
let photos: Vec<_> = fixture["assets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|asset| asset["kind"] == "image")
|
||||
.collect();
|
||||
let queries: Vec<_> = fixture["queries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|query| query["category"] == "image-memory")
|
||||
.collect();
|
||||
for photo in &photos {
|
||||
let content = fs::read(root.join(photo["path"].as_str().unwrap()))?;
|
||||
ensure!(
|
||||
photo["sha256"] == format!("{:x}", Sha256::digest(content)),
|
||||
"Frozen photo changed"
|
||||
);
|
||||
}
|
||||
let mut model = GemmaEmbedder::load_with_device(&args[1], &args[2], 1, Device::Cpu)?;
|
||||
let mut query_vectors = Vec::new();
|
||||
let query_start = Instant::now();
|
||||
for query in &queries {
|
||||
query_vectors.push(model.embed_query(query["text"].as_str().unwrap())?);
|
||||
}
|
||||
let query_embedding_ms = query_start.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut budgets = Vec::new();
|
||||
for budget in [70, 280, 1120] {
|
||||
let start = Instant::now();
|
||||
let mut encoded = Vec::new();
|
||||
for photo in &photos {
|
||||
let path = root.join(photo["path"].as_str().unwrap());
|
||||
let image = model
|
||||
.embed_images_with_metadata(&[&path], budget)?
|
||||
.remove(0);
|
||||
encoded.push(image);
|
||||
}
|
||||
let encoding_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut rows = Vec::new();
|
||||
for (query, query_vector) in queries.iter().zip(&query_vectors) {
|
||||
let rank_start = Instant::now();
|
||||
let mut ranked: Vec<_> = photos
|
||||
.iter()
|
||||
.zip(&encoded)
|
||||
.map(|(photo, image)| {
|
||||
let score: f64 = query_vector
|
||||
.iter()
|
||||
.zip(&image.vector)
|
||||
.map(|(a, b)| f64::from(*a) * f64::from(*b))
|
||||
.sum();
|
||||
json!({"candidate":{"asset":photo["id"],"modality":"image"},"score":score})
|
||||
})
|
||||
.collect();
|
||||
ranked.sort_by(|a, b| {
|
||||
b["score"]
|
||||
.as_f64()
|
||||
.unwrap()
|
||||
.total_cmp(&a["score"].as_f64().unwrap())
|
||||
});
|
||||
rows.push(json!({"query":query,"file_rankings":ranked,"segment_top5":[],"ranking_only_ms":rank_start.elapsed().as_secs_f64()*1000.0}));
|
||||
}
|
||||
let resolutions: Vec<_> = photos.iter().zip(&encoded).map(|(photo,image)| json!({"asset":photo["id"],"encoded_width":image.encoded_width,"encoded_height":image.encoded_height,"token_budget":image.token_budget})).collect();
|
||||
budgets.push(json!({"token_budget":budget,"encoding_ms":encoding_ms,"images":resolutions,"rows":rows}));
|
||||
println!(
|
||||
"Budget {budget}: {} actual photo encodings, {} frozen queries",
|
||||
photos.len(),
|
||||
queries.len()
|
||||
);
|
||||
}
|
||||
fs::write(
|
||||
&args[3],
|
||||
serde_json::to_vec_pretty(
|
||||
&json!({"fixture_sha256":hash,"worker":model.info,"media_fingerprint":GEMMA_MEDIA_FINGERPRINT,"photos":photos.len(),"queries":queries.len(),"query_embedding_ms":query_embedding_ms,"budgets":budgets,"scope":"12-photo-only token-budget comparison; same 24 independently frozen queries, query embeddings computed once and shared across budgets; ranking_only_ms excludes inference; no retrieval threshold or label tuning. Does not certify production search."}),
|
||||
)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Fast actual metadata/keyword check, separate from embedding semantics.
|
||||
use anyhow::{ensure, Result};
|
||||
use searchi_core::SearchIndex;
|
||||
use searchi_media::{probe, FfmpegTools, MediaIdentity, MediaLimits};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs, path::PathBuf, time::Instant};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
ensure!(args.len() == 4, "Supply CORPUS FFMPEG FFPROBE OUTPUT");
|
||||
let root = PathBuf::from(&args[0]).canonicalize()?;
|
||||
let bytes = fs::read(root.join("fixture.json"))?;
|
||||
let hash = format!("{:x}", Sha256::digest(&bytes));
|
||||
let lock: Value = serde_json::from_slice(&fs::read(root.join("labels.lock.json"))?)?;
|
||||
ensure!(lock["fixture_sha256"] == hash, "Frozen labels changed");
|
||||
let fixture: Value = serde_json::from_slice(&bytes)?;
|
||||
let tools = FfmpegTools::new(PathBuf::from(&args[1]), PathBuf::from(&args[2]))?;
|
||||
let limits = MediaLimits::default();
|
||||
let mut index = SearchIndex::open(":memory:")?;
|
||||
index.index_folder(root.join("assets"))?;
|
||||
let extraction_start = Instant::now();
|
||||
for asset in fixture["assets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|asset| asset["kind"] != "image")
|
||||
{
|
||||
let identity = MediaIdentity::read(
|
||||
&root.join(asset["path"].as_str().unwrap()),
|
||||
limits.max_file_bytes,
|
||||
)?;
|
||||
ensure!(
|
||||
asset["sha256"] == identity.sha256,
|
||||
"Frozen source asset changed"
|
||||
);
|
||||
let source = probe(&tools, &identity.path, &limits)?;
|
||||
let tags = source
|
||||
.tags
|
||||
.iter()
|
||||
.map(|(key, value)| format!("{key}: {value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
index.set_media_metadata(
|
||||
&identity.path.to_string_lossy(),
|
||||
&tags,
|
||||
source.duration_ms,
|
||||
identity.bytes,
|
||||
identity.modified_ns,
|
||||
)?;
|
||||
}
|
||||
let extraction_ms = extraction_start.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut rows = Vec::new();
|
||||
for query in fixture["queries"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|query| query.get("metadata").is_some())
|
||||
{
|
||||
let start = Instant::now();
|
||||
let hits = index.search(query["text"].as_str().unwrap(), 20)?;
|
||||
let query_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let file_rankings: Vec<_> = hits.iter().map(|hit| {
|
||||
let asset = fixture["assets"].as_array().unwrap().iter().find(|asset| PathBuf::from(asset["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy()==hit.filename).unwrap();
|
||||
json!({"candidate":{"asset":asset["id"],"modality":"metadata","match_kind":hit.match_kind},"score":hit.score})
|
||||
}).collect();
|
||||
rows.push(
|
||||
json!({"query":query,"file_rankings":file_rankings,"segment_top5":[],"query_ms":query_ms}),
|
||||
);
|
||||
}
|
||||
fs::write(
|
||||
&args[3],
|
||||
serde_json::to_vec_pretty(
|
||||
&json!({"fixture_sha256":hash,"worker":null,"encoding_ms":extraction_ms,"encoding_failures":[],"rows":rows,"scope":"Actual FFprobe stream/format metadata + guarded Searchi core FTS; lexical identity queries, no embedding fallback; encoding_ms measures metadata extraction, not model inference"}),
|
||||
)?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! End-to-end corpus indexing and querying through actual Searchi APIs.
|
||||
use anyhow::{ensure, Result};
|
||||
use searchi_core::{SearchIndex, SearchOptions};
|
||||
use searchi_media::{FfmpegTools, MediaLimits};
|
||||
use searchi_service::HybridSearch;
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs, path::PathBuf, time::Instant};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
ensure!(
|
||||
args.len() == 6 || args.len() == 7,
|
||||
"Supply CORPUS MODEL CPU_WORKER FFMPEG FFPROBE OUTPUT_JSON [AUDIO_WINDOW_SECONDS]"
|
||||
);
|
||||
ensure!(
|
||||
std::env::var("SEARCHI_FORCE_CPU").as_deref() == Ok("1"),
|
||||
"CPU only"
|
||||
);
|
||||
let root = PathBuf::from(&args[0]).canonicalize()?;
|
||||
let bytes = fs::read(root.join("fixture.json"))?;
|
||||
let hash = format!("{:x}", Sha256::digest(&bytes));
|
||||
let lock: Value = serde_json::from_slice(&fs::read(root.join("labels.lock.json"))?)?;
|
||||
ensure!(lock["fixture_sha256"] == hash, "Frozen labels changed");
|
||||
let fixture: Value = serde_json::from_slice(&bytes)?;
|
||||
for asset in fixture["assets"].as_array().unwrap() {
|
||||
let content = fs::read(root.join(asset["path"].as_str().unwrap()))?;
|
||||
ensure!(
|
||||
asset["sha256"] == format!("{:x}", Sha256::digest(content)),
|
||||
"Asset changed"
|
||||
);
|
||||
}
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let db = temporary.path().join("index.sqlite");
|
||||
let mut lexical = SearchIndex::open(&db)?;
|
||||
let filename_index = lexical.index_folder(root.join("assets"))?;
|
||||
let mut hybrid = HybridSearch::open(&db, &args[1], &args[2], 1)?;
|
||||
let tools = FfmpegTools::new(PathBuf::from(&args[3]), PathBuf::from(&args[4]))?;
|
||||
let start = Instant::now();
|
||||
let mut limits = MediaLimits::default();
|
||||
if let Some(seconds) = args.get(6) {
|
||||
let seconds: u32 = seconds.to_string_lossy().parse()?;
|
||||
ensure!(
|
||||
(5..=30).contains(&seconds),
|
||||
"Audio window must be 5–30 seconds"
|
||||
);
|
||||
limits.audio_window_ms = seconds * 1000;
|
||||
}
|
||||
let index_report = hybrid.index_embeddings_with_media(&tools, &limits)?;
|
||||
let encoding_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
println!("Actual pipeline indexed {} chunks", index_report.chunks);
|
||||
hybrid.warmup()?;
|
||||
let mut rows = Vec::new();
|
||||
for query in fixture["queries"].as_array().unwrap() {
|
||||
if query["filters"].get("time_range_s").is_some() {
|
||||
rows.push(json!({"query":query,"unsupported":"Production query API has no temporal-range filter yet"}));
|
||||
continue;
|
||||
}
|
||||
let mut options = SearchOptions::default();
|
||||
if let Some(extension) = query["filters"]["extension"].as_str() {
|
||||
options.extension = Some(extension.into());
|
||||
}
|
||||
let start = Instant::now();
|
||||
let hits = hybrid.search(query["text"].as_str().unwrap(), 20, &options)?;
|
||||
let query_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let file_rankings: Vec<_> = hits.iter().map(|hit| {
|
||||
let asset = fixture["assets"].as_array().unwrap().iter().find(|asset| PathBuf::from(asset["path"].as_str().unwrap()).file_name().unwrap().to_string_lossy() == hit.filename).unwrap();
|
||||
let modality = if hit.match_kind.contains("audio") { "audio" } else if hit.match_kind == "media_metadata" { "metadata" } else { "image" };
|
||||
let mut candidate = json!({"asset":asset["id"],"modality":modality,"match_kind":hit.match_kind,"source":hit.source});
|
||||
if let Some(source) = &hit.source { if source.kind == "timestamp_ms" {
|
||||
let stamp = f64::from(source.number)/1000.0;
|
||||
candidate["start_s"] = json!(stamp);
|
||||
candidate["end_s"] = json!((stamp + if modality == "audio" {f64::from(limits.audio_window_ms)/1000.0} else {2.0}).min(asset["duration_s"].as_f64().unwrap_or(stamp)));
|
||||
candidate["interval_note"] = json!("End inferred from configured pipeline window policy, actual API exposes only start timestamp");
|
||||
}}
|
||||
json!({"candidate":candidate,"score":hit.score})
|
||||
}).collect();
|
||||
rows.push(json!({"query":query,"segment_top5":file_rankings.iter().take(5).cloned().collect::<Vec<_>>(),"file_rankings":file_rankings,"query_ms":query_ms}));
|
||||
}
|
||||
let report = json!({"fixture_sha256":hash,"worker":hybrid.worker_info(),"index_report":index_report,"filename_index":filename_index,"encoding_ms":encoding_ms,"audio_window_ms":limits.audio_window_ms,"audio_overlap_ms":limits.audio_overlap_ms,"encoding_failures":index_report.failures,"rows":rows,"scope":"Actual Searchi hybrid pipeline and native extension filters; temporal-range filter unsupported; segment ends inferred from configured audio/frame policy, exact point timestamps come from actual result API"});
|
||||
fs::write(&args[5], serde_json::to_vec_pretty(&report)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Actual CPU Google embeddings only; labels never enter document/image vectors.
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use searchi_semantic::{Device, GemmaEmbedder, GEMMA_FINGERPRINT, GEMMA_MEDIA_FINGERPRINT};
|
||||
use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{fs, path::PathBuf, time::Instant};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
ensure!(
|
||||
args.len() == 4,
|
||||
"Supply CORPUS_ROOT MODEL_DIR CPU_WORKER OUTPUT_JSON"
|
||||
);
|
||||
ensure!(
|
||||
std::env::var("SEARCHI_FORCE_CPU").as_deref() == Ok("1"),
|
||||
"CPU-only benchmark requires SEARCHI_FORCE_CPU=1"
|
||||
);
|
||||
let root = PathBuf::from(&args[0]);
|
||||
let bytes = fs::read(root.join("fixture.json"))?;
|
||||
let hash = format!("{:x}", Sha256::digest(&bytes));
|
||||
let lock: Value = serde_json::from_slice(&fs::read(root.join("labels.lock.json"))?)?;
|
||||
ensure!(
|
||||
lock["fixture_sha256"] == hash,
|
||||
"Independent labels changed after freezing"
|
||||
);
|
||||
let fixture: Value = serde_json::from_slice(&bytes)?;
|
||||
let candidates: Value =
|
||||
serde_json::from_slice(&fs::read(root.join("derived/candidates.json"))?)?;
|
||||
ensure!(
|
||||
candidates["fixture_sha256"] == hash,
|
||||
"Prepared media belongs to different labels"
|
||||
);
|
||||
let mut model = GemmaEmbedder::load_with_device(&args[1], &args[2], 1, Device::Cpu)?;
|
||||
ensure!(
|
||||
model.info.device.to_lowercase().contains("cpu"),
|
||||
"Worker did not report CPU"
|
||||
);
|
||||
let start = Instant::now();
|
||||
let mut vectors = Vec::new();
|
||||
let mut encoded = Vec::new();
|
||||
let mut encoding_failures = Vec::new();
|
||||
for candidate in candidates["candidates"]
|
||||
.as_array()
|
||||
.context("candidates absent")?
|
||||
{
|
||||
let path = PathBuf::from(candidate["path"].as_str().context("path absent")?);
|
||||
let result = if candidate["modality"] == "image" {
|
||||
model
|
||||
.embed_images_with_budget(
|
||||
&[&path],
|
||||
candidate["token_budget"].as_u64().unwrap() as u32,
|
||||
)
|
||||
.map(|mut rows| rows.remove(0))
|
||||
} else {
|
||||
let bytes = fs::read(&path)?;
|
||||
ensure!(bytes.len() % 4 == 0, "PCM alignment invalid");
|
||||
let samples: Vec<f32> = bytes
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|chunk| f32::from_le_bytes(*chunk))
|
||||
.collect();
|
||||
model.embed_audio_pcm(&samples)
|
||||
};
|
||||
match result {
|
||||
Ok(vector) => {
|
||||
vectors.push(vector);
|
||||
encoded.push(candidate.clone());
|
||||
}
|
||||
Err(error) => {
|
||||
encoding_failures.push(json!({"candidate": candidate, "error": error.to_string()}))
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"Encoded {}/{} candidates",
|
||||
encoded.len(),
|
||||
candidates["candidates"].as_array().unwrap().len()
|
||||
);
|
||||
}
|
||||
let index_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut rows = Vec::new();
|
||||
for query in fixture["queries"].as_array().context("queries absent")? {
|
||||
if query.get("metadata").is_some() {
|
||||
rows.push(json!({"query":query,"unsupported":"Exact metadata requires production lexical lane; direct audio embeddings cannot verify artist/title identities"}));
|
||||
continue;
|
||||
}
|
||||
let start = Instant::now();
|
||||
let embedding = model.embed_query(query["text"].as_str().context("query text absent")?)?;
|
||||
let mut ranked = Vec::new();
|
||||
for (candidate, vector) in encoded.iter().zip(&vectors) {
|
||||
if let Some(modality) = query.get("required_modality") {
|
||||
if candidate["modality"] != *modality {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(filters) = query.get("filters") {
|
||||
if let Some(asset) = filters.get("asset") {
|
||||
if candidate["asset"] != *asset {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(extension) = filters.get("extension").and_then(Value::as_str) {
|
||||
let asset = fixture["assets"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|asset| asset["id"] == candidate["asset"])
|
||||
.unwrap();
|
||||
if !asset["path"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with(&format!(".{extension}"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(range) = filters.get("time_range_s") {
|
||||
let stamp = candidate["start_s"].as_f64().unwrap_or(-1.0);
|
||||
if stamp < range[0].as_f64().unwrap() || stamp > range[1].as_f64().unwrap() {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
ensure!(
|
||||
embedding.len() == vector.len(),
|
||||
"Mixed embedding dimensions"
|
||||
);
|
||||
let score: f64 = embedding
|
||||
.iter()
|
||||
.zip(vector)
|
||||
.map(|(a, b)| f64::from(*a) * f64::from(*b))
|
||||
.sum();
|
||||
ranked.push(json!({"candidate":candidate,"score":score}));
|
||||
}
|
||||
ranked.sort_by(|a, b| {
|
||||
b["score"]
|
||||
.as_f64()
|
||||
.unwrap()
|
||||
.total_cmp(&a["score"].as_f64().unwrap())
|
||||
});
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let file_rankings: Vec<_> = ranked
|
||||
.iter()
|
||||
.filter(|row| seen.insert(row["candidate"]["asset"].as_str().unwrap()))
|
||||
.take(20)
|
||||
.cloned()
|
||||
.collect();
|
||||
rows.push(json!({"query":query,"file_rankings":file_rankings,"segment_top5":ranked.into_iter().take(5).collect::<Vec<_>>(),"query_ms":start.elapsed().as_secs_f64()*1000.0}));
|
||||
}
|
||||
let report = json!({"fixture_sha256":hash,"worker":model.info,"text_fingerprint":GEMMA_FINGERPRINT,"media_fingerprint":GEMMA_MEDIA_FINGERPRINT,"policy":candidates["policy"],"ffmpeg_version":candidates["ffmpeg_version"],"encoding_ms":index_ms,"encoded_candidates":encoded.len(),"encoding_failures":encoding_failures,"rows":rows,"scope":"Direct real-model dense reference baseline; candidate filters here are benchmark reference constraints, not production-filter certification; no rejection threshold is tuned"});
|
||||
fs::write(&args[3], serde_json::to_vec_pretty(&report)?)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import unittest
|
||||
from score import metrics, temporal_hit, timestamp_distance
|
||||
|
||||
|
||||
class ScoringTests(unittest.TestCase):
|
||||
def test_wrong_file_cannot_receive_temporal_credit(self):
|
||||
query = dict(expected=["clip01"], intervals={"clip01": [[3, 5]]})
|
||||
self.assertFalse(temporal_hit(dict(asset="clip02", start_s=3, end_s=5), query))
|
||||
|
||||
def test_coarse_audio_overlap_does_not_count_as_precise_timestamp(self):
|
||||
query = dict(expected=["speech"], intervals={"speech": [[13, 16]]})
|
||||
candidate = dict(asset="speech", start_s=0, end_s=30)
|
||||
self.assertTrue(temporal_hit(candidate, query))
|
||||
self.assertFalse(temporal_hit(candidate, query, point=True))
|
||||
self.assertFalse(temporal_hit(dict(asset="speech", start_s=16, end_s=30), query))
|
||||
self.assertEqual(timestamp_distance(candidate, query), 13)
|
||||
self.assertEqual(timestamp_distance(dict(asset="speech", start_s=15), query), 0)
|
||||
|
||||
def test_visual_frame_cannot_receive_credit_for_a_spoken_topic(self):
|
||||
query = dict(expected=["movie"], intervals={"movie": [[10, 15]]}, required_modality="audio")
|
||||
candidate = dict(asset="movie", modality="image", start_s=12, end_s=14)
|
||||
self.assertFalse(temporal_hit(candidate, query))
|
||||
self.assertIsNone(timestamp_distance(candidate, query))
|
||||
|
||||
def test_no_answers_and_unsupported_cases_do_not_inflate_recall(self):
|
||||
positive = dict(query=dict(expected=["good"], category="image-memory"), file_rankings=[dict(candidate=dict(asset="bad")), dict(candidate=dict(asset="good"))])
|
||||
negative = dict(query=dict(expected=[], category="content-no-answer"), file_rankings=[dict(candidate=dict(asset="bad"))])
|
||||
unsupported = dict(query=dict(expected=["good"], category="exact-metadata"), unsupported="metadata unavailable")
|
||||
result = metrics([positive, negative, unsupported])
|
||||
self.assertEqual(result["positive_queries"], 1)
|
||||
self.assertEqual(result["recall_at1"], 0)
|
||||
self.assertEqual(result["recall_at5"], 1)
|
||||
self.assertEqual(result["mrr"], .5)
|
||||
self.assertEqual(result["no_answer_return_rate"], 1)
|
||||
self.assertEqual(result["unsupported_queries"], 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,32 @@
|
||||
# Standalone media decoder tools
|
||||
|
||||
The desktop distribution must include FFmpeg and FFprobe alongside its Google
|
||||
worker. End users should not install tools or configure paths. Development and
|
||||
release preparation use:
|
||||
|
||||
```sh
|
||||
python tools/media-runtime/provision.py --stage
|
||||
python -m unittest discover -s tools/media-runtime -p 'test_*.py'
|
||||
```
|
||||
|
||||
The manifest pins a dated BtbN release, exact archive size, and published SHA-256.
|
||||
Provisioning checks the cache or downloads/verifies the archive, extracts only the
|
||||
two standalone decoder executables and notices, and stages them under the native
|
||||
resources directory. Executables and archives are ignored by Git. No global
|
||||
installation occurs. Movie/audio decoding runs in bounded subprocesses.
|
||||
|
||||
Windows x86-64/ARM64 and Linux x86-64/ARM64 have pinned artifacts. Only Windows
|
||||
x86-64 has been executed here. A vetted macOS decoder build/staging path remains
|
||||
a release gate; generic media code can accept native tools through explicit paths.
|
||||
The selected build is FFmpeg 9.0.2 with LGPL configuration. Keep original notices
|
||||
and corresponding source/build provenance with distribution; installer licensing
|
||||
and all-platform execution are still open production gates.
|
||||
|
||||
Source/build provenance:
|
||||
- https://github.com/BtbN/FFmpeg-Builds/releases/tag/autobuild-2026-10-12-13-12
|
||||
- https://github.com/BtbN/FFmpeg-Builds
|
||||
- https://ffmpeg.org/legal.html
|
||||
|
||||
FFmpeg capabilities vary by build. An unsupported or corrupt file must report an
|
||||
error while remaining filename-searchable; it must never freeze search or be
|
||||
silently reported as fully indexed.
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"provider": "BtbN/FFmpeg-Builds",
|
||||
"release": "autobuild-2026-10-12-13-12",
|
||||
"version": "9.0.2-27-g7e93014c5b",
|
||||
"platforms": {
|
||||
"windows-x86_64": {
|
||||
"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-win64-lgpl-9.0.zip",
|
||||
"bytes": 171476229,
|
||||
"sha256": "104a019fe34c0747b208bde4842a0bac8b70a307cc2bb33aa4648fc15ddb2e39"
|
||||
},
|
||||
"windows-aarch64": {
|
||||
"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-winarm64-lgpl-9.0.zip",
|
||||
"bytes": 118006941,
|
||||
"sha256": "68d3bac585b80b6f17e459eb4a203ed1f01b7635d4c611f1685de1ae65ca0987"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-linux64-lgpl-9.0.tar.xz",
|
||||
"bytes": 137901812,
|
||||
"sha256": "12445b38a4700ba1ae7c9c7e57062f602c927dc4187555bfadc9f3e2f75f05b0"
|
||||
},
|
||||
"linux-aarch64": {
|
||||
"archive": "ffmpeg-n9.0.2-27-g7e93014c5b-linuxarm64-lgpl-9.0.tar.xz",
|
||||
"bytes": 117008524,
|
||||
"sha256": "c9160ae8440c17c016fb2f6b3fcce4bb9d633a15845e908b2303948a7724f227"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Verify and stage standalone FFmpeg tools. No user-wide installation or GPU use."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MAX_MEMBER = 256 * 1024 * 1024
|
||||
|
||||
|
||||
def verified(path, artifact):
|
||||
if not path.is_file() or path.stat().st_size != artifact["bytes"]:
|
||||
return False
|
||||
with path.open("rb") as stream:
|
||||
return hashlib.file_digest(stream, "sha256").hexdigest() == artifact["sha256"]
|
||||
|
||||
|
||||
def install(stream, target, expected):
|
||||
if not 0 < expected <= MAX_MEMBER:
|
||||
raise ValueError("Archive member exceeds size ceiling")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = target.with_suffix(target.suffix + ".partial")
|
||||
try:
|
||||
with temporary.open("wb") as output:
|
||||
copied = 0
|
||||
while block := stream.read(65536):
|
||||
copied += len(block)
|
||||
if copied > expected:
|
||||
raise ValueError("Archive member exceeds declared length")
|
||||
output.write(block)
|
||||
if copied != expected:
|
||||
raise ValueError("Archive member is truncated")
|
||||
if target.name in {"ffmpeg", "ffprobe"}:
|
||||
temporary.chmod(0o755)
|
||||
os.replace(temporary, target)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def extract(archive, destination, windows):
|
||||
required = {"ffmpeg.exe", "ffprobe.exe"} if windows else {"ffmpeg", "ffprobe"}
|
||||
found = set()
|
||||
def target_for(name):
|
||||
# Never extract arbitrary paths: selected basenames map to fixed targets.
|
||||
basename = name.replace("\\", "/").split("/")[-1]
|
||||
if basename in required:
|
||||
if basename in found:
|
||||
raise ValueError("Duplicate decoder executable")
|
||||
found.add(basename)
|
||||
return destination / "bin" / basename
|
||||
if basename.lower().startswith(("license", "copying")) or basename.lower() in {"readme.txt", "readme.md"}:
|
||||
parts = name.replace("\\", "/").split("/")
|
||||
if any(part in {".", "..", ""} or ":" in part for part in parts):
|
||||
raise ValueError("Unsafe notice path")
|
||||
return destination / "licenses" / Path(*parts)
|
||||
return None
|
||||
if archive.suffix == ".zip":
|
||||
with zipfile.ZipFile(archive) as package:
|
||||
for member in package.infolist():
|
||||
target = target_for(member.filename)
|
||||
if target is not None and not member.is_dir():
|
||||
with package.open(member) as stream:
|
||||
install(stream, target, member.file_size)
|
||||
else:
|
||||
with tarfile.open(archive, "r:xz") as package:
|
||||
for member in package:
|
||||
target = target_for(member.name)
|
||||
if target is not None:
|
||||
if not member.isfile():
|
||||
raise ValueError("Selected archive member is not a regular file")
|
||||
with package.extractfile(member) as stream:
|
||||
install(stream, target, member.size)
|
||||
if found != required:
|
||||
raise ValueError("Archive is missing required decoder tools")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--stage", action="store_true", help="Copy verified decoder tools and notices into Tauri resources")
|
||||
args = parser.parse_args()
|
||||
manifest = json.loads(Path(__file__).with_name("manifest.json").read_text())
|
||||
machine = platform.machine().lower()
|
||||
architecture = "aarch64" if machine in {"arm64", "aarch64"} else "x86_64" if machine in {"amd64", "x86_64"} else machine
|
||||
system = platform.system().lower()
|
||||
key = f"{system}-{architecture}"
|
||||
if key not in manifest["platforms"]:
|
||||
raise RuntimeError(f"No verified decoder artifact for {key}; macOS packaging remains a release gate")
|
||||
artifact = manifest["platforms"][key]
|
||||
directory = ROOT / ".searchi/media-runtime"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
archive = directory / artifact["archive"]
|
||||
if not verified(archive, artifact):
|
||||
url = f"https://github.com/{manifest['provider']}/releases/download/{manifest['release']}/{artifact['archive']}"
|
||||
with tempfile.NamedTemporaryFile(dir=directory, delete=False) as output:
|
||||
temporary = Path(output.name)
|
||||
try:
|
||||
with urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "searchi-runtime-setup"}), timeout=120) as response:
|
||||
copied = 0
|
||||
while block := response.read(65536):
|
||||
copied += len(block)
|
||||
if copied > artifact["bytes"]:
|
||||
raise ValueError("Decoder download exceeds manifest size")
|
||||
output.write(block)
|
||||
output.close()
|
||||
if not verified(temporary, artifact):
|
||||
raise ValueError("Decoder size/SHA-256 mismatch")
|
||||
os.replace(temporary, archive)
|
||||
finally:
|
||||
output.close()
|
||||
temporary.unlink(missing_ok=True)
|
||||
extract(archive, directory, system == "windows")
|
||||
if args.stage:
|
||||
destination = ROOT / "apps/desktop/src-tauri/resources/media-runtime"
|
||||
shutil.copytree(directory / "bin", destination / "bin", dirs_exist_ok=True)
|
||||
shutil.copytree(directory / "licenses", destination / "licenses", dirs_exist_ok=True)
|
||||
shutil.copy2(Path(__file__).with_name("manifest.json"), destination / "manifest.json")
|
||||
print(f"Verified media tools available at {directory / 'bin'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,60 @@
|
||||
import hashlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
spec = importlib.util.spec_from_file_location("media_provision", Path(__file__).with_name("provision.py"))
|
||||
provision = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(provision)
|
||||
|
||||
|
||||
class ProvisionTests(unittest.TestCase):
|
||||
def package(self, directory, entries):
|
||||
archive = directory / "sample.zip"
|
||||
with zipfile.ZipFile(archive, "w") as package:
|
||||
for name, data in entries:
|
||||
package.writestr(name, data)
|
||||
return archive
|
||||
|
||||
def test_selects_only_tools_and_preserves_distinct_notices(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
archive = self.package(directory, [("build/bin/ffmpeg.exe", b"tool1"),
|
||||
("build/bin/ffprobe.exe", b"tool2"), ("build/README.txt", b"readme"),
|
||||
("build/LICENSE", b"first license"), ("build/dependency/LICENSE", b"second license"),
|
||||
("../../unselected.exe", b"unselected")])
|
||||
target = directory / "out"
|
||||
provision.extract(archive, target, True)
|
||||
self.assertEqual((target / "bin/ffmpeg.exe").read_bytes(), b"tool1")
|
||||
self.assertEqual(len(list((target / "licenses").rglob("LICENSE"))), 2)
|
||||
self.assertFalse((directory / "unselected.exe").exists())
|
||||
|
||||
def test_missing_and_duplicate_executables_are_explicit_failures(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
for entries in [[("bin/ffmpeg.exe", b"tool")],
|
||||
[("a/ffmpeg.exe", b"tool"), ("b/ffmpeg.exe", b"second"), ("bin/ffprobe.exe", b"probe")]]:
|
||||
with self.assertRaises(ValueError):
|
||||
provision.extract(self.package(directory, entries), directory / "out", True)
|
||||
|
||||
def test_same_size_corruption_is_not_a_verified_cache(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary) / "archive"
|
||||
path.write_bytes(b"good")
|
||||
artifact = {"bytes": 4, "sha256": hashlib.sha256(b"good").hexdigest()}
|
||||
self.assertTrue(provision.verified(path, artifact))
|
||||
path.write_bytes(b"evil")
|
||||
self.assertFalse(provision.verified(path, artifact))
|
||||
|
||||
def test_notice_traversal_cannot_escape_output(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
directory = Path(temporary)
|
||||
archive = self.package(directory, [("../../LICENSE", b"escape")])
|
||||
with self.assertRaises(ValueError):
|
||||
provision.extract(archive, directory / "out", True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user