Merge pull request 'feature/insight-chat-improvements' (#83) from feature/insight-chat-improvements into master

Reviewed-on: #83
This commit was merged in pull request #83.
This commit is contained in:
2026-05-07 22:19:12 +00:00
14 changed files with 1347 additions and 354 deletions

View File

@@ -640,6 +640,10 @@ pub struct ChatTurnHttpRequest {
pub min_p: Option<f32>,
#[serde(default)]
pub max_iterations: Option<usize>,
/// Per-turn system-prompt override. Ephemeral in append mode,
/// persisted in amend mode. See ChatTurnRequest for semantics.
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default)]
pub amend: bool,
}
@@ -695,6 +699,7 @@ pub async fn chat_turn_handler(
top_k: request.top_k,
min_p: request.min_p,
max_iterations: request.max_iterations,
system_prompt: request.system_prompt.clone(),
amend: request.amend,
};
@@ -909,6 +914,7 @@ pub async fn chat_stream_handler(
top_k: request.top_k,
min_p: request.min_p,
max_iterations: request.max_iterations,
system_prompt: request.system_prompt.clone(),
amend: request.amend,
};

View File

@@ -45,6 +45,11 @@ pub struct ChatTurnRequest {
pub top_k: Option<i32>,
pub min_p: Option<f32>,
pub max_iterations: Option<usize>,
/// Per-turn system-prompt override. In append mode (default), applied
/// ephemerally — original system message restored before persistence.
/// In amend mode, persisted into the new insight row's system message.
/// None / empty = no change.
pub system_prompt: Option<String>,
/// When true, write a new insight row (regenerating title) instead of
/// updating training_messages on the existing row.
pub amend: bool,
@@ -359,10 +364,12 @@ impl InsightChatService {
.map(|imgs| !imgs.is_empty())
.unwrap_or(false);
let offer_describe_tool = !is_hybrid && local_first_user_has_image;
let tools = InsightGenerator::build_tool_definitions(
offer_describe_tool,
self.generator.apollo_enabled(),
);
// current_gate_opts(has_vision) sets gate_opts.has_vision = has_vision
// and probes the per-table presence flags. Pass `offer_describe_tool`
// directly — the `!is_hybrid && local_first_user_has_image` decision
// is the chat-path's vision predicate.
let gate_opts = self.generator.current_gate_opts(offer_describe_tool);
let tools = InsightGenerator::build_tool_definitions(gate_opts);
// Image base64 only needed when describe_photo is on the menu. Load
// lazily to avoid disk IO when the loop never invokes it.
@@ -385,6 +392,13 @@ impl InsightChatService {
// 7. Append the new user turn.
messages.push(ChatMessage::user(req.user_message.clone()));
// Apply per-turn system-prompt override BEFORE the budget annotation
// so the budget note attaches to the override, not the original.
// The stash is consumed below before persistence (append mode) or
// dropped (amend mode, where the override stays in place).
let override_stash =
apply_system_prompt_override(&mut messages, req.system_prompt.as_deref());
// Temporarily annotate the system message with this turn's iteration
// budget so the model knows how many tool-calling rounds it has. We
// restore the original content before persistence so the note doesn't
@@ -481,6 +495,14 @@ impl InsightChatService {
// before we persist so it doesn't snowball on each subsequent turn.
restore_system_content(&mut messages, original_system_content);
// Append mode: undo the per-turn system-prompt override so the
// stored transcript keeps the original baked persona. Amend mode:
// keep the override in place — it becomes the new insight row's
// system message.
if !req.amend {
restore_system_prompt_override(&mut messages, override_stash);
}
// 9. Persist. Append mode rewrites the JSON blob in place; amend
// mode regenerates the title and inserts a new insight row,
// relying on store_insight to flip prior rows' is_current=false.
@@ -790,10 +812,12 @@ impl InsightChatService {
.map(|imgs| !imgs.is_empty())
.unwrap_or(false);
let offer_describe_tool = !is_hybrid && local_first_user_has_image;
let tools = InsightGenerator::build_tool_definitions(
offer_describe_tool,
self.generator.apollo_enabled(),
);
// current_gate_opts(has_vision) sets gate_opts.has_vision = has_vision
// and probes the per-table presence flags. Pass `offer_describe_tool`
// directly — the `!is_hybrid && local_first_user_has_image` decision
// is the chat-path's vision predicate.
let gate_opts = self.generator.current_gate_opts(offer_describe_tool);
let tools = InsightGenerator::build_tool_definitions(gate_opts);
let image_base64: Option<String> = if offer_describe_tool {
self.generator.load_image_as_base64(&normalized).ok()
@@ -812,6 +836,10 @@ impl InsightChatService {
messages.push(ChatMessage::user(req.user_message.clone()));
// Mirror chat_turn: per-turn override goes on first, budget note next.
let override_stash =
apply_system_prompt_override(&mut messages, req.system_prompt.as_deref());
let original_system_content = annotate_system_with_budget(&mut messages, max_iterations);
let mut tool_calls_made = 0usize;
@@ -946,6 +974,13 @@ impl InsightChatService {
// before we persist so it doesn't snowball on each subsequent turn.
restore_system_content(&mut messages, original_system_content);
// Append mode: undo the per-turn system-prompt override (mirrors
// chat_turn). Amend mode: keep the override — it becomes the new
// insight row's system message.
if !req.amend {
restore_system_prompt_override(&mut messages, override_stash);
}
// Persist.
let json = serde_json::to_string(&messages)
.map_err(|e| anyhow!("failed to serialize chat history: {}", e))?;
@@ -1153,6 +1188,64 @@ fn restore_system_content(messages: &mut [ChatMessage], original: Option<String>
}
}
/// Receipt produced by [`apply_system_prompt_override`] so the caller can
/// undo the override before persistence. Two variants because we either
/// replaced an existing system message (need its original content) or
/// prepended a synthetic one (need to pop it).
#[derive(Debug)]
pub(crate) enum SystemPromptStash {
Replaced { original: String },
Prepended,
}
/// Apply a per-turn `system_prompt` override to `messages` so the model
/// sees the requested persona for this turn. Returns a stash the caller
/// must pass to [`restore_system_prompt_override`] before persisting the
/// transcript — without that step, append-mode chat would silently
/// rewrite the stored persona.
///
/// No-op (returns `None`) when `override_prompt` is `None` or empty.
pub(crate) fn apply_system_prompt_override(
messages: &mut Vec<ChatMessage>,
override_prompt: Option<&str>,
) -> Option<SystemPromptStash> {
let prompt = override_prompt
.map(str::trim)
.filter(|s| !s.is_empty())?
.to_string();
if let Some(first) = messages.first_mut()
&& first.role == "system"
{
let original = std::mem::replace(&mut first.content, prompt);
return Some(SystemPromptStash::Replaced { original });
}
messages.insert(0, ChatMessage::system(prompt));
Some(SystemPromptStash::Prepended)
}
/// Undo an override previously applied by [`apply_system_prompt_override`].
/// No-op when `stash` is `None`.
pub(crate) fn restore_system_prompt_override(
messages: &mut Vec<ChatMessage>,
stash: Option<SystemPromptStash>,
) {
let Some(stash) = stash else { return };
match stash {
SystemPromptStash::Replaced { original } => {
if let Some(first) = messages.first_mut()
&& first.role == "system"
{
first.content = original;
}
}
SystemPromptStash::Prepended => {
if matches!(messages.first(), Some(m) if m.role == "system") {
messages.remove(0);
}
}
}
}
/// View returned to clients for chat-UI rendering.
#[derive(Debug)]
pub struct HistoryView {
@@ -1386,4 +1479,94 @@ mod tests {
let cut = find_raw_cut(&msgs, 2).expect("boundary cut should succeed");
assert_eq!(cut, msgs.len());
}
#[test]
fn apply_override_replaces_existing_system_message() {
let mut msgs = vec![
ChatMessage::system("original persona"),
ChatMessage::user("hi"),
];
let stash = apply_system_prompt_override(&mut msgs, Some("new persona"));
assert_eq!(msgs[0].content, "new persona");
match stash {
Some(SystemPromptStash::Replaced { original }) => {
assert_eq!(original, "original persona");
}
other => panic!("expected Replaced, got {:?}", other),
}
}
#[test]
fn apply_override_prepends_synthetic_when_missing() {
let mut msgs = vec![ChatMessage::user("hi")];
let stash = apply_system_prompt_override(&mut msgs, Some("new persona"));
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "system");
assert_eq!(msgs[0].content, "new persona");
assert!(matches!(stash, Some(SystemPromptStash::Prepended)));
}
#[test]
fn apply_override_no_op_when_none() {
let mut msgs = vec![ChatMessage::system("sys"), ChatMessage::user("hi")];
let stash = apply_system_prompt_override(&mut msgs, None);
assert!(stash.is_none());
assert_eq!(msgs[0].content, "sys");
}
#[test]
fn apply_override_no_op_for_empty_string() {
let mut msgs = vec![ChatMessage::system("sys")];
let stash = apply_system_prompt_override(&mut msgs, Some(""));
assert!(stash.is_none());
assert_eq!(msgs[0].content, "sys");
}
#[test]
fn restore_override_replaces_back() {
let mut msgs = vec![ChatMessage::system("new"), ChatMessage::user("hi")];
restore_system_prompt_override(
&mut msgs,
Some(SystemPromptStash::Replaced {
original: "original".to_string(),
}),
);
assert_eq!(msgs[0].content, "original");
assert_eq!(msgs.len(), 2);
}
#[test]
fn restore_override_pops_synthetic() {
let mut msgs = vec![ChatMessage::system("new"), ChatMessage::user("hi")];
restore_system_prompt_override(&mut msgs, Some(SystemPromptStash::Prepended));
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "user");
}
#[test]
fn override_round_trip_preserves_original_system_message() {
let mut msgs = vec![
ChatMessage::system("original persona"),
ChatMessage::user("first user"),
assistant_text("first reply"),
];
let stash = apply_system_prompt_override(&mut msgs, Some("ephemeral persona"));
assert_eq!(msgs[0].content, "ephemeral persona");
restore_system_prompt_override(&mut msgs, stash);
assert_eq!(msgs[0].content, "original persona");
assert_eq!(msgs.len(), 3);
assert_eq!(msgs[1].role, "user");
assert_eq!(msgs[2].role, "assistant");
}
#[test]
fn override_with_synthetic_round_trip_drops_extra_message() {
let mut msgs = vec![ChatMessage::user("first user")];
let stash = apply_system_prompt_override(&mut msgs, Some("ephemeral"));
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "system");
restore_system_prompt_override(&mut msgs, stash);
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "user");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,31 +20,36 @@ impl SmsApiClient {
}
}
/// Fetch messages for a specific contact within ±4 days of the given timestamp
/// Falls back to all contacts if no messages found for the specific contact
/// Messages are sorted by proximity to the center timestamp
/// Compute a `[start, end]` unix-second window of `2 * radius_days`
/// centered on `center_ts`. `radius_days < 1` is clamped to 1 to avoid
/// degenerate zero-width windows.
pub(crate) fn window_for_radius(center_ts: i64, radius_days: i64) -> (i64, i64) {
let r = radius_days.max(1);
let span = r * 86400;
(center_ts - span, center_ts + span)
}
/// Fetch messages for a specific contact within ±`radius_days` of the
/// given timestamp. Falls back to all contacts when no messages found
/// for the named contact. Sorted by proximity to the center timestamp.
pub async fn fetch_messages_for_contact(
&self,
contact: Option<&str>,
center_timestamp: i64,
radius_days: i64,
) -> Result<Vec<SmsMessage>> {
use chrono::Duration;
let effective_radius = radius_days.max(1);
let (start_ts, end_ts) = Self::window_for_radius(center_timestamp, radius_days);
// Calculate ±4 days range around the center timestamp
let center_dt = chrono::DateTime::from_timestamp(center_timestamp, 0)
.ok_or_else(|| anyhow::anyhow!("Invalid timestamp"))?;
let start_dt = center_dt - Duration::days(4);
let end_dt = center_dt + Duration::days(4);
let start_ts = start_dt.timestamp();
let end_ts = end_dt.timestamp();
// If contact specified, try fetching for that contact first
if let Some(contact_name) = contact {
log::info!(
"Fetching SMS for contact: {} (±4 days from {})",
"Fetching SMS for contact: {} (±{} days from {})",
contact_name,
effective_radius,
center_dt.format("%Y-%m-%d %H:%M:%S")
);
let messages = self
@@ -68,7 +73,8 @@ impl SmsApiClient {
// Fallback to all contacts
log::info!(
"Fetching all SMS messages (±4 days from {})",
"Fetching all SMS messages (±{} days from {})",
effective_radius,
center_dt.format("%Y-%m-%d %H:%M:%S")
);
self.fetch_messages(start_ts, end_ts, None, Some(center_timestamp))
@@ -255,19 +261,26 @@ impl SmsApiClient {
/// - "fts5" keyword-only, supports phrase / prefix / boolean / NEAR
/// - "semantic" embedding similarity
/// - "hybrid" both merged via reciprocal rank fusion (recommended)
pub async fn search_messages(
///
/// The SMS-API endpoint accepts `contact_id` natively; date filtering is
/// the caller's responsibility (post-filter on the returned rows).
pub async fn search_messages_with_contact(
&self,
query: &str,
mode: &str,
limit: usize,
contact_id: Option<i64>,
) -> Result<Vec<SmsSearchHit>> {
let url = format!(
let mut url = format!(
"{}/api/messages/search/?q={}&mode={}&limit={}",
self.base_url,
urlencoding::encode(query),
urlencoding::encode(mode),
limit
);
if let Some(cid) = contact_id {
url.push_str(&format!("&contact_id={}", cid));
}
let mut request = self.client.get(&url);
if let Some(token) = &self.token {
@@ -379,3 +392,29 @@ struct SmsSearchResponse {
#[serde(default)]
search_method: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_for_radius_produces_2n_day_span() {
let center: i64 = 1_700_000_000;
let (start, end) = SmsApiClient::window_for_radius(center, 7);
assert_eq!(end - start, 14 * 86400);
assert_eq!(start + 7 * 86400, center);
assert_eq!(end - 7 * 86400, center);
}
#[test]
fn window_for_radius_clamps_zero_to_one() {
let (start, end) = SmsApiClient::window_for_radius(100_000, 0);
assert_eq!(end - start, 2 * 86400);
}
#[test]
fn window_for_radius_clamps_negative_to_one() {
let (start, end) = SmsApiClient::window_for_radius(100_000, -7);
assert_eq!(end - start, 2 * 86400);
}
}

View File

@@ -14,6 +14,7 @@ use image_api::database::{
SqliteInsightDao, SqliteKnowledgeDao, SqliteLocationHistoryDao, SqliteSearchHistoryDao,
connect,
};
use image_api::faces::{FaceDao, SqliteFaceDao};
use image_api::file_types::{IMAGE_EXTENSIONS, VIDEO_EXTENSIONS};
use image_api::libraries::{self, Library};
use image_api::tags::{SqliteTagDao, TagDao};
@@ -182,6 +183,8 @@ async fn main() -> anyhow::Result<()> {
Arc::new(Mutex::new(Box::new(SqliteTagDao::default())));
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
let face_dao: Arc<Mutex<Box<dyn FaceDao>>> =
Arc::new(Mutex::new(Box::new(SqliteFaceDao::new())));
// Pass the full library set so `resolve_full_path` probes every root,
// even when --library restricts the walk. A rel_path shared across
@@ -198,6 +201,7 @@ async fn main() -> anyhow::Result<()> {
location_dao,
search_dao,
tag_dao,
face_dao,
knowledge_dao,
all_libs.clone(),
);

View File

@@ -75,6 +75,11 @@ pub trait DailySummaryDao: Sync + Send {
context: &opentelemetry::Context,
contact: &str,
) -> Result<i64, DbError>;
/// Cheap presence check — returns true iff at least one daily summary row
/// exists. Used by gating logic that only needs "is the table empty?",
/// avoiding a `COUNT(*)` full scan on large corpora.
fn has_any_summaries(&mut self, context: &opentelemetry::Context) -> Result<bool, DbError>;
}
pub struct SqliteDailySummaryDao {
@@ -454,6 +459,30 @@ impl DailySummaryDao for SqliteDailySummaryDao {
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
fn has_any_summaries(&mut self, context: &opentelemetry::Context) -> Result<bool, DbError> {
trace_db_call(context, "query", "has_any_summaries", |_span| {
let mut conn = self
.connection
.lock()
.expect("Unable to get DailySummaryDao");
#[derive(QueryableByName)]
struct ProbeResult {
#[diesel(sql_type = diesel::sql_types::Integer)]
#[allow(dead_code)]
one: i32,
}
let rows: Vec<ProbeResult> =
diesel::sql_query("SELECT 1 as one FROM daily_conversation_summaries LIMIT 1")
.load(conn.deref_mut())
.map_err(|e| anyhow::anyhow!("Failed to probe daily summaries: {}", e))?;
Ok(!rows.is_empty())
})
.map_err(|_| DbError::new(DbErrorKind::QueryError))
}
}
// Helper structs for raw SQL queries

View File

@@ -503,6 +503,10 @@ pub trait FaceDao: Send + Sync {
into: i32,
) -> anyhow::Result<Person>;
/// Cheap presence probe — returns true iff at least one face has been
/// detected (excluding marker rows). Used by chat-tool gating.
fn has_any_faces(&mut self, ctx: &opentelemetry::Context) -> anyhow::Result<bool>;
/// Resolve `(library_id, rel_path)` → `content_hash` via image_exif.
/// Returns None when the photo hasn't been EXIF-indexed yet (no row
/// in image_exif) or when the row exists but content_hash is NULL.
@@ -1432,6 +1436,19 @@ impl FaceDao for SqliteFaceDao {
})
}
fn has_any_faces(&mut self, ctx: &opentelemetry::Context) -> anyhow::Result<bool> {
let mut conn = self.connection.lock().expect("face dao lock");
trace_db_call(ctx, "query", "has_any_faces", |_span| {
face_detections::table
.filter(face_detections::status.eq("detected"))
.select(face_detections::id)
.first::<i32>(conn.deref_mut())
.optional()
.map(|x| x.is_some())
.with_context(|| "has_any_faces query")
})
}
fn resolve_content_hash(
&mut self,
ctx: &opentelemetry::Context,

View File

@@ -1718,7 +1718,12 @@ mod tests {
// Mock — files.rs tests don't exercise the date-override endpoints.
// Returning a synthetic row keeps the trait satisfied without
// depending on private DbError constructors.
Ok(mock_exif_row(library_id, rel_path, Some(date_taken), Some("manual".to_string())))
Ok(mock_exif_row(
library_id,
rel_path,
Some(date_taken),
Some("manual".to_string()),
))
}
fn clear_manual_date_taken(

View File

@@ -995,10 +995,8 @@ async fn upload_image(
}
};
let perceptual = perceptual_hash::compute(&uploaded_path);
let resolved_date = date_resolver::resolve_date_taken(
&uploaded_path,
exif_data.date_taken,
);
let resolved_date =
date_resolver::resolve_date_taken(&uploaded_path, exif_data.date_taken);
let insert_exif = InsertImageExif {
library_id: target_library.id,
file_path: relative_path.clone(),
@@ -1022,8 +1020,7 @@ async fn upload_image(
size_bytes,
phash_64: perceptual.map(|h| h.phash_64),
dhash_64: perceptual.map(|h| h.dhash_64),
date_taken_source: resolved_date
.map(|r| r.source.as_str().to_string()),
date_taken_source: resolved_date.map(|r| r.source.as_str().to_string()),
};
if let Ok(mut dao) = exif_dao.lock() {
@@ -1687,7 +1684,16 @@ fn create_thumbnails(libs: &[libraries::Library], excluded_dirs: &[String]) {
]);
debug!("Generating video thumbnail: {:?}", thumb_path);
generate_video_thumbnail(src, &thumb_path);
if let Err(e) = generate_video_thumbnail(src, &thumb_path) {
let sentinel = unsupported_thumbnail_sentinel(&thumb_path);
error!(
"Unable to thumbnail video {:?}: {}. Writing sentinel {:?}",
src, e, sentinel
);
if let Err(se) = std::fs::write(&sentinel, b"") {
warn!("Failed to write sentinel {:?}: {}", sentinel, se);
}
}
video_span.end();
} else if is_image(&entry) {
match generate_image_thumbnail(src, &thumb_path) {

View File

@@ -213,10 +213,7 @@ pub fn extract_date_from_filename(filename: &str) -> Option<DateTime<FixedOffset
// dispatch on the source-app prefix instead.
const NON_TIMESTAMP_PREFIXES: &[&str] = &["snapchat-"];
let lower = filename.to_ascii_lowercase();
if NON_TIMESTAMP_PREFIXES
.iter()
.any(|p| lower.starts_with(p))
{
if NON_TIMESTAMP_PREFIXES.iter().any(|p| lower.starts_with(p)) {
return None;
}

View File

@@ -10,6 +10,7 @@ use crate::database::{
connect,
};
use crate::database::{PreviewDao, SqlitePreviewDao};
use crate::faces;
use crate::libraries::{self, Library, LibraryHealthMap};
use crate::tags::{SqliteTagDao, TagDao};
use crate::video::actors::{
@@ -206,6 +207,8 @@ impl Default for AppState {
Arc::new(Mutex::new(Box::new(SqliteTagDao::default())));
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
let face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>> =
Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new())));
// Load base path and ensure the primary library row reflects it.
let base_path = env::var("BASE_PATH").expect("BASE_PATH was not set in the env");
@@ -231,6 +234,7 @@ impl Default for AppState {
location_dao.clone(),
search_dao.clone(),
tag_dao.clone(),
face_dao.clone(),
knowledge_dao,
libraries_vec.clone(),
);
@@ -348,6 +352,8 @@ impl AppState {
Arc::new(Mutex::new(Box::new(SqliteTagDao::default())));
let knowledge_dao: Arc<Mutex<Box<dyn KnowledgeDao>>> =
Arc::new(Mutex::new(Box::new(SqliteKnowledgeDao::new())));
let face_dao: Arc<Mutex<Box<dyn faces::FaceDao>>> =
Arc::new(Mutex::new(Box::new(faces::SqliteFaceDao::new())));
// Initialize test InsightGenerator with all data sources
let base_path_str = base_path.to_string_lossy().to_string();
@@ -370,6 +376,7 @@ impl AppState {
location_dao.clone(),
search_dao.clone(),
tag_dao.clone(),
face_dao.clone(),
knowledge_dao,
vec![test_lib],
);

View File

@@ -107,19 +107,39 @@ pub async fn create_playlist(video_path: &str, playlist_file: &str) -> Result<Ch
result
}
pub fn generate_video_thumbnail(path: &Path, destination: &Path) {
Command::new("ffmpeg")
pub fn generate_video_thumbnail(path: &Path, destination: &Path) -> std::io::Result<()> {
// -vf scale + -c:v mjpeg mirrors `generate_image_thumbnail_ffmpeg`. The
// filter chain matters as much as the scale does: without it, ffmpeg
// hands the decoded frame straight to the mjpeg encoder, which rejects
// any non-yuvj420p source ("Non full-range YUV is non-standard"). The
// filter chain lets ffmpeg auto-insert the pix_fmt converter the
// encoder needs, which is how the image-thumbnail path already handles
// the same class of source.
let output = Command::new("ffmpeg")
.arg("-y")
.arg("-ss")
.arg("3")
.arg("-i")
.arg(path.to_str().unwrap())
.arg(path)
.arg("-vframes")
.arg("1")
.arg("-vf")
.arg("scale=200:-1")
.arg("-f")
.arg("image2")
.arg("-c:v")
.arg("mjpeg")
.arg(destination)
.output()
.expect("Failure to create video frame");
.output()?;
if !output.status.success() {
return Err(std::io::Error::other(format!(
"ffmpeg failed ({}): {}",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
)));
}
Ok(())
}
/// Use ffmpeg to extract a 200px-wide thumbnail from formats the `image` crate