1#[cfg(feature = "audio-playback")]
4use std::io::Cursor;
5use std::io::{self, Write};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8#[cfg(feature = "audio-playback")]
9use std::time::Duration;
10
11use anyhow::{anyhow, bail, Context, Result};
12use clap::Args;
13use memvid_core::table::list_tables;
14use memvid_core::{
15 lockfile, normalize_text, Frame, FrameRole, MediaManifest, Memvid, TextChunkManifest,
16 TextChunkRange,
17};
18use serde_json::{json, Value};
19use tempfile::Builder;
20use tracing::warn;
21use uuid::Uuid;
22
23use crate::config::CliConfig;
24use crate::utils::{
25 format_bytes, format_percent, format_timestamp_ms, frame_status_str, open_read_only_mem,
26 owner_hint_to_json, parse_timecode, round_percent, select_frame, yes_no,
27};
28
29const DEFAULT_VIEW_PAGE_CHARS: usize = 1_200;
30const CHUNK_MANIFEST_KEY: &str = "memvid_chunks_v1";
31
32#[derive(Args)]
34pub struct ViewArgs {
35 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
36 pub file: PathBuf,
37 #[arg(long = "frame-id", value_name = "ID", conflicts_with = "uri")]
38 pub frame_id: Option<u64>,
39 #[arg(long, value_name = "URI", conflicts_with = "frame_id")]
40 pub uri: Option<String>,
41 #[arg(long)]
42 pub json: bool,
43 #[arg(long, conflicts_with = "json")]
44 pub binary: bool,
45 #[arg(long, conflicts_with_all = ["json", "binary"])]
46 pub preview: bool,
47 #[arg(
49 long = "start",
50 value_name = "HH:MM:SS",
51 requires = "preview",
52 conflicts_with_all = ["json", "binary", "play"]
53 )]
54 pub preview_start: Option<String>,
55 #[arg(
57 long = "end",
58 value_name = "HH:MM:SS",
59 requires = "preview",
60 conflicts_with_all = ["json", "binary", "play"]
61 )]
62 pub preview_end: Option<String>,
63 #[arg(long = "play", conflicts_with_all = ["json", "binary", "preview"])]
64 pub play: bool,
65 #[arg(long = "start-seconds", requires = "play")]
66 pub start_seconds: Option<f32>,
67 #[arg(long = "end-seconds", requires = "play")]
68 pub end_seconds: Option<f32>,
69 #[arg(long, value_name = "N", default_value_t = 1)]
70 pub page: usize,
71 #[arg(long = "page-size", value_name = "CHARS")]
72 pub page_size: Option<usize>,
73}
74
75#[derive(Args)]
77pub struct StatsArgs {
78 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
79 pub file: PathBuf,
80 #[arg(long)]
81 pub json: bool,
82 #[arg(long = "as-of-frame", value_name = "FRAME_ID")]
84 pub as_of_frame: Option<u64>,
85 #[arg(long = "as-of-ts", value_name = "UNIX_TIMESTAMP")]
87 pub as_of_ts: Option<i64>,
88}
89
90#[derive(Args)]
92pub struct WhoArgs {
93 #[arg(value_name = "FILE", value_parser = clap::value_parser!(PathBuf))]
94 pub file: PathBuf,
95 #[arg(long)]
96 pub json: bool,
97}
98
99pub fn handle_stats(_config: &CliConfig, args: StatsArgs) -> Result<()> {
101 let mut mem = Memvid::open_read_only(&args.file)?;
102 let stats = mem.stats()?;
103 let tables = list_tables(&mut mem).unwrap_or_default();
104
105 if args.as_of_frame.is_some() || args.as_of_ts.is_some() {
108 eprintln!("Note: Replay filtering (--as-of-frame/--as-of-ts) shows current stats.");
109 eprintln!(" Use 'find' or 'timeline' commands for filtered results.");
110 }
111 let overhead_bytes = stats.size_bytes.saturating_sub(stats.payload_bytes);
112 let payload_share_percent: f64 = if stats.size_bytes > 0 {
113 round_percent((stats.payload_bytes as f64 / stats.size_bytes as f64) * 100.0)
114 } else {
115 0.0
116 };
117 let overhead_share_percent: f64 = if stats.size_bytes > 0 {
118 round_percent((100.0 - payload_share_percent).max(0.0))
119 } else {
120 0.0
121 };
122 let maintenance_command = format!(
123 "memvid doctor {} --vacuum --rebuild-time-index --rebuild-lex-index",
124 args.file.display()
125 );
126
127 if args.json {
128 let mut raw_json = serde_json::to_value(&stats)?;
129 if let Value::Object(ref mut obj) = raw_json {
130 obj.remove("tier");
131 }
132
133 let tables_json: Vec<serde_json::Value> = tables
135 .iter()
136 .map(|t| {
137 json!({
138 "table_id": t.table_id,
139 "source_file": t.source_file,
140 "n_rows": t.n_rows,
141 "n_cols": t.n_cols,
142 "pages": format!("{}-{}", t.page_start, t.page_end),
143 "quality": format!("{:?}", t.quality),
144 "headers": t.headers,
145 })
146 })
147 .collect();
148
149 let report = json!({
150 "summary": {
151 "sequence": stats.seq_no,
152 "frames": format!("{} total ({} active)", stats.frame_count, stats.active_frame_count),
153 "usage": format!(
154 "{} used / {} total ({})",
155 format_bytes(stats.size_bytes),
156 format_bytes(stats.capacity_bytes),
157 format_percent(stats.storage_utilisation_percent)
158 ),
159 "remaining": format!("{} free", format_bytes(stats.remaining_capacity_bytes)),
160 },
161 "storage": {
162 "payload": format!("{} ({})", format_bytes(stats.payload_bytes), format_percent(payload_share_percent)),
163 "overhead": format!("{} ({}) - WAL + indexes", format_bytes(overhead_bytes), format_percent(overhead_share_percent)),
164 "logical_payload": format!("{} before compression", format_bytes(stats.logical_bytes)),
165 "compression_savings": format!("{} saved ({})", format_bytes(stats.saved_bytes), format_percent(stats.savings_percent)),
166 "compression_ratio": format_percent(stats.compression_ratio_percent),
167 },
168 "frames": {
169 "average_stored": format_bytes(stats.average_frame_payload_bytes),
170 "average_logical": format_bytes(stats.average_frame_logical_bytes),
171 },
172 "indexes": {
173 "lexical": yes_no(stats.has_lex_index),
174 "vector": yes_no(stats.has_vec_index),
175 "time": yes_no(stats.has_time_index),
176 },
177 "tables": {
178 "count": tables.len(),
179 "tables": tables_json,
180 },
181 "maintenance": maintenance_command,
182 "raw": raw_json,
183 });
184
185 println!("{}", serde_json::to_string_pretty(&report)?);
186 } else {
187 let seq_display = stats
188 .seq_no
189 .map(|seq| seq.to_string())
190 .unwrap_or_else(|| "n/a".to_string());
191
192 println!("Memory: {}", args.file.display());
193 println!("Sequence: {}", seq_display);
194 println!(
195 "Frames: {} total ({} active)",
196 stats.frame_count, stats.active_frame_count
197 );
198
199 println!("\nCapacity:");
200 println!(
201 " Usage: {} used / {} total ({})",
202 format_bytes(stats.size_bytes),
203 format_bytes(stats.capacity_bytes),
204 format_percent(stats.storage_utilisation_percent)
205 );
206 println!(
207 " Remaining: {}",
208 format_bytes(stats.remaining_capacity_bytes)
209 );
210
211 println!("\nStorage breakdown:");
212 println!(
213 " Payload: {} ({})",
214 format_bytes(stats.payload_bytes),
215 format_percent(payload_share_percent)
216 );
217 println!(
218 " Overhead: {} ({})",
219 format_bytes(overhead_bytes),
220 format_percent(overhead_share_percent)
221 );
222 println!(" ├─ WAL: {}", format_bytes(stats.wal_bytes));
224 println!(
225 " ├─ Lexical index: {}",
226 format_bytes(stats.lex_index_bytes)
227 );
228 println!(
229 " ├─ Vector index: {}",
230 format_bytes(stats.vec_index_bytes)
231 );
232 println!(
233 " └─ Time index: {}",
234 format_bytes(stats.time_index_bytes)
235 );
236 println!(
237 " Logical payload: {} before compression",
238 format_bytes(stats.logical_bytes)
239 );
240 println!(
241 " Compression savings: {} ({})",
242 format_bytes(stats.saved_bytes),
243 format_percent(stats.savings_percent)
244 );
245
246 println!("\nAverage frame:");
247 println!(
248 " Stored: {} Logical: {}",
249 format_bytes(stats.average_frame_payload_bytes),
250 format_bytes(stats.average_frame_logical_bytes)
251 );
252
253 if stats.active_frame_count > 0 {
255 let overhead_per_doc = overhead_bytes / stats.active_frame_count;
256 let lex_per_doc = stats.lex_index_bytes / stats.active_frame_count;
257 let vec_per_doc = stats.vec_index_bytes / stats.active_frame_count;
258
259 println!("\nPer-document overhead:");
260 println!(" Total: {}", format_bytes(overhead_per_doc));
261 if stats.has_lex_index {
262 println!(" Lexical: {}", format_bytes(lex_per_doc));
263 }
264 if stats.has_vec_index {
265 let vec_ratio = if stats.average_frame_payload_bytes > 0 {
266 vec_per_doc as f64 / stats.average_frame_payload_bytes as f64
267 } else {
268 0.0
269 };
270 println!(
271 " Vector: {} ({:.0}x text size)",
272 format_bytes(vec_per_doc),
273 vec_ratio
274 );
275 }
276 }
277
278 println!("\nIndexes:");
279 println!(
280 " Lexical: {} Vector: {} Time: {}",
281 yes_no(stats.has_lex_index),
282 yes_no(stats.has_vec_index),
283 yes_no(stats.has_time_index)
284 );
285
286 if !tables.is_empty() {
287 println!("\nTables: {} extracted", tables.len());
288 for t in &tables {
289 println!(
290 " {} — {} rows × {} cols ({})",
291 t.table_id, t.n_rows, t.n_cols, t.source_file
292 );
293 }
294 }
295
296 println!("\nMaintenance:");
297 println!(
298 " Run `{}` to rebuild indexes and reclaim space.",
299 maintenance_command
300 );
301 }
302 Ok(())
303}
304
305pub fn handle_who(args: WhoArgs) -> Result<()> {
307 match lockfile::current_owner(&args.file)? {
308 Some(owner) => {
309 if args.json {
310 let output = json!({
311 "locked": true,
312 "owner": owner_hint_to_json(&owner),
313 });
314 println!("{}", serde_json::to_string_pretty(&output)?);
315 } else {
316 println!("{} is locked by:", args.file.display());
317 if let Some(pid) = owner.pid {
318 println!(" pid: {pid}");
319 }
320 if let Some(cmd) = owner.cmd.as_deref() {
321 println!(" cmd: {cmd}");
322 }
323 if let Some(started) = owner.started_at.as_deref() {
324 println!(" started_at: {started}");
325 }
326 if let Some(last) = owner.last_heartbeat.as_deref() {
327 println!(" last_heartbeat: {last}");
328 }
329 if let Some(interval) = owner.heartbeat_ms {
330 println!(" heartbeat_interval_ms: {interval}");
331 }
332 if let Some(file_id) = owner.file_id.as_deref() {
333 println!(" file_id: {file_id}");
334 }
335 if let Some(path) = owner.file_path.as_ref() {
336 println!(" file_path: {}", path.display());
337 }
338 }
339 }
340 None => {
341 if args.json {
342 let output = json!({"locked": false});
343 println!("{}", serde_json::to_string_pretty(&output)?);
344 } else {
345 println!("No active writer for {}", args.file.display());
346 }
347 }
348 }
349 Ok(())
350}
351
352pub fn handle_view(args: ViewArgs) -> Result<()> {
358 if args.page == 0 {
359 bail!("page must be greater than zero");
360 }
361 if let Some(size) = args.page_size {
362 if size == 0 {
363 bail!("page-size must be greater than zero");
364 }
365 }
366
367 let mut mem = open_read_only_mem(&args.file)?;
368 let frame = select_frame(&mut mem, args.frame_id, args.uri.as_deref())?;
369
370 if args.play {
371 #[cfg(feature = "audio-playback")]
372 {
373 play_frame_audio(&mut mem, &frame, args.start_seconds, args.end_seconds)?;
374 return Ok(());
375 }
376 #[cfg(not(feature = "audio-playback"))]
377 {
378 bail!("Audio playback requires the 'audio-playback' feature (only available on macOS)");
379 }
380 }
381
382 if args.preview {
383 let bounds = parse_preview_bounds(args.preview_start.as_ref(), args.preview_end.as_ref())?;
384 preview_frame_media(&mut mem, &frame, args.uri.as_deref(), bounds)?;
385 return Ok(());
386 }
387
388 if args.binary {
389 let bytes = mem.frame_canonical_payload(frame.id)?;
390 let mut stdout = io::stdout();
391 stdout.write_all(&bytes)?;
392 stdout.flush()?;
393 return Ok(());
394 }
395
396 let canonical_text = canonical_text_for_view(&mut mem, &frame)?;
397 let manifest_from_meta = canonical_manifest_from_frame(&canonical_text, &frame);
398
399 let page_size = args
400 .page_size
401 .or_else(|| manifest_from_meta.as_ref().map(|m| m.chunk_chars))
402 .unwrap_or(DEFAULT_VIEW_PAGE_CHARS);
403
404 let mut manifest = if args.page_size.is_none() {
405 manifest_from_meta.unwrap_or_else(|| compute_chunk_manifest(&canonical_text, page_size))
406 } else {
407 compute_chunk_manifest(&canonical_text, page_size)
408 };
409 if manifest.chunks.is_empty() {
410 manifest = TextChunkManifest {
411 chunk_chars: page_size,
412 chunks: vec![TextChunkRange {
413 start: 0,
414 end: canonical_text.chars().count(),
415 }],
416 };
417 }
418
419 if frame.role == FrameRole::DocumentChunk && args.page_size.is_none() {
420 let total_chars = canonical_text.chars().count();
421 manifest = TextChunkManifest {
422 chunk_chars: total_chars.max(1),
423 chunks: vec![TextChunkRange {
424 start: 0,
425 end: total_chars,
426 }],
427 };
428 }
429
430 let total_pages = manifest.chunks.len().max(1);
431 if args.page > total_pages {
432 bail!(
433 "page {} is out of range (total pages: {})",
434 args.page,
435 total_pages
436 );
437 }
438
439 let chunk = &manifest.chunks[args.page - 1];
440 let content = extract_chunk_slice(&canonical_text, chunk);
441
442 if args.json {
443 let mut frame_json = frame_to_json(&frame);
444 if let Some(obj) = frame_json.as_object_mut() {
445 if let Some(manifest_json) = obj.get_mut("chunk_manifest") {
448 if let Some(manifest_obj) = manifest_json.as_object_mut() {
449 let total = manifest.chunks.len();
450 if total > 0 {
451 let mut window = serde_json::Map::new();
452 let idx = args.page.saturating_sub(1).min(total - 1);
453 if idx > 0 {
454 let prev = &manifest.chunks[idx - 1];
455 window.insert("prev".into(), json!([prev.start, prev.end]));
456 }
457 let current = &manifest.chunks[idx];
458 window.insert("current".into(), json!([current.start, current.end]));
459 if idx + 1 < total {
460 let next = &manifest.chunks[idx + 1];
461 window.insert("next".into(), json!([next.start, next.end]));
462 }
463 manifest_obj.insert("chunks".into(), Value::Object(window));
464 }
465 }
466 }
467 }
468 let json = json!({
469 "frame": frame_json,
470 "page": args.page,
471 "page_size": manifest.chunk_chars,
472 "page_count": total_pages,
473 "has_prev": args.page > 1,
474 "has_next": args.page < total_pages,
475 "content": content,
476 });
477 println!("{}", serde_json::to_string_pretty(&json)?);
478 } else {
479 print_frame_summary(&mut mem, &frame)?;
480 println!(
481 "Page {}/{} ({} chars per page)",
482 args.page, total_pages, manifest.chunk_chars
483 );
484 println!();
485 println!("{}", content);
486 }
487 Ok(())
488}
489
490#[derive(Debug)]
491pub struct PreviewBounds {
492 pub start_ms: Option<u64>,
493 pub end_ms: Option<u64>,
494}
495
496pub fn parse_preview_bounds(
497 start: Option<&String>,
498 end: Option<&String>,
499) -> Result<Option<PreviewBounds>> {
500 let start_ms = match start {
501 Some(value) => Some(parse_timecode(value)?),
502 None => None,
503 };
504 let end_ms = match end {
505 Some(value) => Some(parse_timecode(value)?),
506 None => None,
507 };
508
509 if let (Some(s), Some(e)) = (start_ms, end_ms) {
510 if e <= s {
511 anyhow::bail!("--end must be greater than --start");
512 }
513 }
514
515 if start_ms.is_none() && end_ms.is_none() {
516 Ok(None)
517 } else {
518 Ok(Some(PreviewBounds { start_ms, end_ms }))
519 }
520}
521
522fn preview_frame_media(
523 mem: &mut Memvid,
524 frame: &Frame,
525 cli_uri: Option<&str>,
526 bounds: Option<PreviewBounds>,
527) -> Result<()> {
528 let manifest = mem.media_manifest(frame.id)?;
529 let mime = manifest
530 .as_ref()
531 .map(|m| m.mime.clone())
532 .or_else(|| frame.metadata.as_ref().and_then(|meta| meta.mime.clone()))
533 .unwrap_or_else(|| "application/octet-stream".to_string());
534 let is_video = manifest
535 .as_ref()
536 .map(|media| media.kind.eq_ignore_ascii_case("video"))
537 .unwrap_or_else(|| mime.starts_with("video/"));
538
539 if is_video {
540 preview_frame_video(mem, frame, cli_uri, bounds, manifest, &mime)?;
541 } else {
542 if bounds.is_some() {
543 anyhow::bail!("--start/--end are only supported for video previews");
544 }
545 if is_image_mime(&mime) {
546 preview_frame_image(mem, frame, cli_uri)?;
547 } else if is_audio_mime(&mime) {
548 preview_frame_audio_file(mem, frame, cli_uri, manifest.as_ref(), &mime)?;
549 } else {
550 preview_frame_document(mem, frame, cli_uri, manifest.as_ref(), &mime)?;
551 }
552 }
553 Ok(())
554}
555
556fn preview_frame_video(
557 mem: &mut Memvid,
558 frame: &Frame,
559 cli_uri: Option<&str>,
560 bounds: Option<PreviewBounds>,
561 manifest: Option<MediaManifest>,
562 mime: &str,
563) -> Result<()> {
564 let extension = manifest
565 .as_ref()
566 .and_then(|m| m.filename.as_deref())
567 .and_then(|name| Path::new(name).extension().and_then(|ext| ext.to_str()))
568 .map(|ext| ext.trim_start_matches('.').to_ascii_lowercase())
569 .or_else(|| extension_from_mime(mime).map(|ext| ext.to_string()))
570 .unwrap_or_else(|| "mp4".to_string());
571
572 let mut temp_file = Builder::new()
573 .prefix("memvid-preview-")
574 .suffix(&format!(".{extension}"))
575 .tempfile_in(std::env::temp_dir())
576 .context("failed to create temporary preview file")?;
577
578 let mut reader = mem
579 .blob_reader(frame.id)
580 .context("failed to stream payload for preview")?;
581 io::copy(&mut reader, &mut temp_file).context("failed to write video data to preview file")?;
582 temp_file
583 .flush()
584 .context("failed to flush video preview to disk")?;
585
586 let (file, preview_path) = temp_file.keep().context("failed to persist preview file")?;
587 drop(file);
588
589 let mut display_path = preview_path.clone();
590 if let Some(ref span) = bounds {
591 let needs_trim = span.start_ms.is_some() || span.end_ms.is_some();
592 if needs_trim {
593 if let Some(trimmed) = maybe_trim_with_ffmpeg(&preview_path, &extension, span)? {
594 display_path = trimmed;
595 }
596 }
597 }
598
599 println!("Opening preview...");
600 open::that(&display_path).with_context(|| {
601 format!(
602 "failed to launch default video player for {}",
603 display_path.display()
604 )
605 })?;
606
607 let display_uri = cli_uri
608 .or_else(|| frame.uri.as_deref())
609 .unwrap_or("<unknown>");
610 println!(
611 "Opened preview for {} (frame {}) -> {} ({})",
612 display_uri,
613 frame.id,
614 display_path.display(),
615 mime
616 );
617 Ok(())
618}
619
620fn maybe_trim_with_ffmpeg(
621 source: &Path,
622 extension: &str,
623 bounds: &PreviewBounds,
624) -> Result<Option<PathBuf>> {
625 if bounds.start_ms.is_none() && bounds.end_ms.is_none() {
626 return Ok(None);
627 }
628
629 let ffmpeg = match which::which("ffmpeg") {
630 Ok(path) => path,
631 Err(_) => {
632 warn!("ffmpeg binary not found on PATH; opening full video");
633 return Ok(None);
634 }
635 };
636
637 let target = std::env::temp_dir().join(format!(
638 "memvid-preview-clip-{}.{}",
639 Uuid::new_v4(),
640 extension
641 ));
642
643 let mut command = Command::new(ffmpeg);
644 command.arg("-y");
645 if let Some(start) = bounds.start_ms {
646 command.arg("-ss").arg(format_timestamp_ms(start));
647 }
648 command.arg("-i").arg(source);
649 if let Some(end) = bounds.end_ms {
650 command.arg("-to").arg(format_timestamp_ms(end));
651 }
652 command.arg("-c").arg("copy");
653 command.arg(&target);
654
655 let status = command
656 .status()
657 .context("failed to run ffmpeg for preview trimming")?;
658 if status.success() {
659 return Ok(Some(target));
660 }
661
662 let details = status
663 .code()
664 .map(|code| code.to_string())
665 .unwrap_or_else(|| "terminated".to_string());
666 warn!("ffmpeg exited with status {details}; opening full video");
667 Ok(None)
668}
669
670fn preview_frame_image(mem: &mut Memvid, frame: &Frame, cli_uri: Option<&str>) -> Result<()> {
671 let bytes = mem
672 .frame_canonical_payload(frame.id)
673 .context("failed to load canonical payload for frame")?;
674 if bytes.is_empty() {
675 bail!("frame payload is empty; nothing to preview");
676 }
677
678 let detected_kind = infer::get(&bytes);
679 let mut mime = frame
680 .metadata
681 .as_ref()
682 .and_then(|meta| meta.mime.clone())
683 .filter(|value| is_image_mime(value));
684
685 if mime.is_none() {
686 if let Some(kind) = &detected_kind {
687 let candidate = kind.mime_type();
688 if is_image_mime(candidate) {
689 mime = Some(candidate.to_string());
690 }
691 }
692 }
693
694 let mime = mime.ok_or_else(|| anyhow!("frame does not contain an image payload"))?;
695 if !is_image_mime(&mime) {
696 bail!("frame mime type {mime} is not an image");
697 }
698
699 let extension = detected_kind
700 .as_ref()
701 .map(|kind| kind.extension().to_string())
702 .or_else(|| extension_from_mime(&mime).map(|ext| ext.to_string()))
703 .unwrap_or_else(|| "img".to_string());
704
705 let suffix = format!(".{extension}");
706 let mut temp_file = Builder::new()
707 .prefix("memvid-preview-")
708 .suffix(&suffix)
709 .tempfile_in(std::env::temp_dir())
710 .context("failed to create temporary preview file")?;
711 temp_file
712 .write_all(&bytes)
713 .context("failed to write image data to preview file")?;
714 temp_file
715 .flush()
716 .context("failed to flush preview file to disk")?;
717
718 let (file, preview_path) = temp_file.keep().context("failed to persist preview file")?;
719 drop(file);
720
721 println!("Opening preview...");
722 open::that(&preview_path).with_context(|| {
723 format!(
724 "failed to launch default image viewer for {}",
725 preview_path.display()
726 )
727 })?;
728
729 let display_uri = cli_uri
730 .or_else(|| frame.uri.as_deref())
731 .unwrap_or("<unknown>");
732 println!(
733 "Opened preview for {} (frame {}) -> {} ({})",
734 display_uri,
735 frame.id,
736 preview_path.display(),
737 mime
738 );
739 Ok(())
740}
741
742fn preview_frame_document(
743 mem: &mut Memvid,
744 frame: &Frame,
745 cli_uri: Option<&str>,
746 manifest: Option<&MediaManifest>,
747 mime: &str,
748) -> Result<()> {
749 let bytes = mem
750 .frame_canonical_payload(frame.id)
751 .context("failed to load canonical payload for frame")?;
752 if bytes.is_empty() {
753 bail!("frame payload is empty; nothing to preview");
754 }
755
756 let mut extension = manifest
757 .and_then(|m| m.filename.as_deref())
758 .and_then(|name| Path::new(name).extension().and_then(|ext| ext.to_str()))
759 .map(|ext| ext.trim_start_matches('.').to_string())
760 .or_else(|| extension_from_mime(mime).map(|ext| ext.to_string()))
761 .unwrap_or_else(|| "bin".to_string());
762
763 if extension == "bin" && std::str::from_utf8(&bytes).is_ok() {
764 extension = "txt".to_string();
765 }
766
767 let suffix = format!(".{extension}");
768 let mut temp_file = Builder::new()
769 .prefix("memvid-preview-")
770 .suffix(&suffix)
771 .tempfile_in(std::env::temp_dir())
772 .context("failed to create temporary preview file")?;
773 temp_file
774 .write_all(&bytes)
775 .context("failed to write document data to preview file")?;
776 temp_file
777 .flush()
778 .context("failed to flush preview file to disk")?;
779
780 let (file, preview_path) = temp_file.keep().context("failed to persist preview file")?;
781 drop(file);
782
783 println!("Opening preview...");
784 open::that(&preview_path).with_context(|| {
785 format!(
786 "failed to launch default viewer for {}",
787 preview_path.display()
788 )
789 })?;
790
791 let display_uri = cli_uri
792 .or_else(|| frame.uri.as_deref())
793 .unwrap_or("<unknown>");
794 println!(
795 "Opened preview for {} (frame {}) -> {} ({})",
796 display_uri,
797 frame.id,
798 preview_path.display(),
799 mime
800 );
801 Ok(())
802}
803
804fn preview_frame_audio_file(
805 mem: &mut Memvid,
806 frame: &Frame,
807 cli_uri: Option<&str>,
808 manifest: Option<&MediaManifest>,
809 mime: &str,
810) -> Result<()> {
811 let bytes = mem
812 .frame_canonical_payload(frame.id)
813 .context("failed to load canonical payload for frame")?;
814 if bytes.is_empty() {
815 bail!("frame payload is empty; nothing to preview");
816 }
817
818 let mut extension = manifest
819 .and_then(|m| m.filename.as_deref())
820 .and_then(|name| Path::new(name).extension().and_then(|ext| ext.to_str()))
821 .map(|ext| ext.trim_start_matches('.').to_string())
822 .or_else(|| extension_from_mime(mime).map(|ext| ext.to_string()))
823 .unwrap_or_else(|| "audio".to_string());
824
825 if extension == "bin" {
826 extension = "audio".to_string();
827 }
828
829 let suffix = format!(".{extension}");
830 let mut temp_file = Builder::new()
831 .prefix("memvid-preview-")
832 .suffix(&suffix)
833 .tempfile_in(std::env::temp_dir())
834 .context("failed to create temporary preview file")?;
835 temp_file
836 .write_all(&bytes)
837 .context("failed to write audio data to preview file")?;
838 temp_file
839 .flush()
840 .context("failed to flush preview file to disk")?;
841
842 let (file, preview_path) = temp_file.keep().context("failed to persist preview file")?;
843 drop(file);
844
845 println!("Opening preview...");
846 open::that(&preview_path).with_context(|| {
847 format!(
848 "failed to launch default audio player for {}",
849 preview_path.display()
850 )
851 })?;
852
853 let display_uri = cli_uri
854 .or_else(|| frame.uri.as_deref())
855 .unwrap_or("<unknown>");
856 println!(
857 "Opened preview for {} (frame {}) -> {} ({})",
858 display_uri,
859 frame.id,
860 preview_path.display(),
861 mime
862 );
863 Ok(())
864}
865
866#[cfg(feature = "audio-playback")]
867fn play_frame_audio(
868 mem: &mut Memvid,
869 frame: &Frame,
870 start_seconds: Option<f32>,
871 end_seconds: Option<f32>,
872) -> Result<()> {
873 use rodio::Source;
874
875 if let (Some(start), Some(end)) = (start_seconds, end_seconds) {
876 if end <= start {
877 bail!("--end-seconds must be greater than --start-seconds");
878 }
879 }
880
881 let bytes = mem
882 .frame_canonical_payload(frame.id)
883 .context("failed to load canonical payload for frame")?;
884 if bytes.is_empty() {
885 bail!("frame payload is empty; nothing to play");
886 }
887
888 let start = start_seconds.unwrap_or(0.0).max(0.0);
889 let duration_meta = frame
890 .metadata
891 .as_ref()
892 .and_then(|meta| meta.audio.as_ref())
893 .and_then(|audio| audio.duration_secs)
894 .unwrap_or(0.0);
895
896 if duration_meta > 0.0 && start >= duration_meta {
897 bail!("start-seconds ({start:.2}) exceeds audio duration ({duration_meta:.2})");
898 }
899
900 if let Some(end) = end_seconds {
901 if duration_meta > 0.0 && end > duration_meta + f32::EPSILON {
902 warn!(
903 "requested end-seconds {:.2} exceeds known duration {:.2}; clamping",
904 end, duration_meta
905 );
906 }
907 }
908
909 let cursor = Cursor::new(bytes);
910 let decoder = rodio::Decoder::new(cursor).context("failed to decode audio stream")?;
911 let (_stream, stream_handle) =
912 rodio::OutputStream::try_default().context("failed to open default audio output")?;
913 let sink = rodio::Sink::try_new(&stream_handle).context("failed to create audio sink")?;
914 let display_uri = frame.uri.as_deref().unwrap_or("<unknown>");
915
916 if let Some(end) = end_seconds {
917 let effective_end = if duration_meta > 0.0 {
918 end.min(duration_meta)
919 } else {
920 end
921 };
922 let duration = (effective_end - start).max(0.0);
923 if duration <= 0.0 {
924 bail!("playback duration is zero; adjust start/end seconds");
925 }
926 let source = decoder
927 .skip_duration(Duration::from_secs_f32(start))
928 .take_duration(Duration::from_secs_f32(duration));
929 sink.append(source);
930 let segment_desc = format!("{start:.2}s → {effective_end:.2}s");
931 announce_playback(display_uri, &segment_desc);
932 } else {
933 let source = decoder.skip_duration(Duration::from_secs_f32(start));
934 sink.append(source);
935 let segment_desc = format!("{start:.2}s → end");
936 announce_playback(display_uri, &segment_desc);
937 }
938 sink.sleep_until_end();
939 Ok(())
940}
941
942#[cfg(feature = "audio-playback")]
943fn announce_playback(uri: &str, segment_desc: &str) {
944 println!("Playing {uri} ({segment_desc})");
945}
946
947fn is_image_mime(value: &str) -> bool {
948 let normalized = value.split(';').next().unwrap_or(value).trim();
949 normalized.to_ascii_lowercase().starts_with("image/")
950}
951
952fn is_audio_mime(value: &str) -> bool {
953 let normalized = value.split(';').next().unwrap_or(value).trim();
954 normalized.to_ascii_lowercase().starts_with("audio/")
955}
956
957pub fn extension_from_mime(mime: &str) -> Option<&'static str> {
958 let normalized = mime
959 .split(';')
960 .next()
961 .unwrap_or(mime)
962 .trim()
963 .to_ascii_lowercase();
964 match normalized.as_str() {
965 "image/jpeg" | "image/jpg" => Some("jpg"),
966 "image/png" => Some("png"),
967 "image/gif" => Some("gif"),
968 "image/webp" => Some("webp"),
969 "image/bmp" => Some("bmp"),
970 "image/tiff" => Some("tiff"),
971 "image/x-icon" | "image/vnd.microsoft.icon" => Some("ico"),
972 "image/svg+xml" => Some("svg"),
973 "video/mp4" | "video/iso.segment" => Some("mp4"),
974 "video/quicktime" => Some("mov"),
975 "video/webm" => Some("webm"),
976 "video/x-matroska" | "video/matroska" => Some("mkv"),
977 "video/x-msvideo" => Some("avi"),
978 "video/mpeg" => Some("mpg"),
979 "application/pdf" => Some("pdf"),
980 "audio/mpeg" | "audio/mp3" => Some("mp3"),
981 "audio/wav" | "audio/x-wav" => Some("wav"),
982 "audio/x-flac" | "audio/flac" => Some("flac"),
983 "audio/ogg" | "audio/vorbis" => Some("ogg"),
984 "audio/x-m4a" | "audio/mp4" => Some("m4a"),
985 "audio/aac" => Some("aac"),
986 "audio/x-aiff" | "audio/aiff" => Some("aiff"),
987 "text/plain" => Some("txt"),
988 "text/markdown" | "text/x-markdown" => Some("md"),
989 "text/html" => Some("html"),
990 "application/xhtml+xml" => Some("xhtml"),
991 "application/json" | "text/json" | "application/vnd.api+json" => Some("json"),
992 "application/xml" | "text/xml" => Some("xml"),
993 "text/csv" | "application/csv" => Some("csv"),
994 "application/javascript" | "text/javascript" => Some("js"),
995 "text/css" => Some("css"),
996 "application/yaml" | "application/x-yaml" | "text/yaml" => Some("yaml"),
997 "application/rtf" => Some("rtf"),
998 "application/msword" => Some("doc"),
999 "application/vnd.openxmlformats-officedocument.wordprocessingml.document" => Some("docx"),
1000 "application/vnd.ms-powerpoint" => Some("ppt"),
1001 "application/vnd.openxmlformats-officedocument.presentationml.presentation" => Some("pptx"),
1002 "application/vnd.ms-excel" => Some("xls"),
1003 "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => Some("xlsx"),
1004 "application/zip" => Some("zip"),
1005 "application/x-tar" => Some("tar"),
1006 "application/x-7z-compressed" => Some("7z"),
1007 _ => None,
1008 }
1009}
1010pub fn search_snippet(text: Option<&String>) -> Option<String> {
1011 text.and_then(|value| {
1012 let trimmed = value.trim();
1013 if trimmed.is_empty() {
1014 None
1015 } else {
1016 Some(trimmed.chars().take(160).collect())
1017 }
1018 })
1019}
1020pub fn frame_to_json(frame: &Frame) -> serde_json::Value {
1021 json!({
1022 "id": frame.id,
1023 "status": frame_status_str(frame.status),
1024 "timestamp": frame.timestamp,
1025 "kind": frame.kind,
1026 "track": frame.track,
1027 "uri": frame.uri,
1028 "title": frame.title,
1029 "payload_length": frame.payload_length,
1030 "canonical_encoding": format!("{:?}", frame.canonical_encoding),
1031 "canonical_length": frame.canonical_length,
1032 "role": format!("{:?}", frame.role),
1033 "parent_id": frame.parent_id,
1034 "chunk_index": frame.chunk_index,
1035 "chunk_count": frame.chunk_count,
1036 "tags": frame.tags,
1037 "labels": frame.labels,
1038 "search_text": frame.search_text,
1039 "metadata": frame.metadata,
1040 "extra_metadata": frame.extra_metadata,
1041 "content_dates": frame.content_dates,
1042 "chunk_manifest": frame.chunk_manifest,
1043 "supersedes": frame.supersedes,
1044 "superseded_by": frame.superseded_by,
1045 })
1046}
1047pub fn print_frame_summary(mem: &mut Memvid, frame: &Frame) -> Result<()> {
1048 println!("Frame {} [{}]", frame.id, frame_status_str(frame.status));
1049 println!("Timestamp: {}", frame.timestamp);
1050 if let Some(uri) = &frame.uri {
1051 println!("URI: {uri}");
1052 }
1053 if let Some(title) = &frame.title {
1054 println!("Title: {title}");
1055 }
1056 if let Some(kind) = &frame.kind {
1057 println!("Kind: {kind}");
1058 }
1059 if let Some(track) = &frame.track {
1060 println!("Track: {track}");
1061 }
1062 if let Some(supersedes) = frame.supersedes {
1063 println!("Supersedes frame: {supersedes}");
1064 }
1065 if let Some(successor) = frame.superseded_by {
1066 println!("Superseded by frame: {successor}");
1067 }
1068 println!(
1069 "Payload: {} bytes (canonical {:?}, logical {:?})",
1070 frame.payload_length, frame.canonical_encoding, frame.canonical_length
1071 );
1072 if !frame.tags.is_empty() {
1073 println!("Tags: {}", frame.tags.join(", "));
1074 }
1075 if !frame.labels.is_empty() {
1076 println!("Labels: {}", frame.labels.join(", "));
1077 }
1078 if let Some(snippet) = search_snippet(frame.search_text.as_ref()) {
1079 println!("Search text: {snippet}");
1080 }
1081 if let Some(meta) = &frame.metadata {
1082 let rendered = serde_json::to_string_pretty(meta)?;
1083 println!("Metadata: {rendered}");
1084 }
1085 if !frame.extra_metadata.is_empty() {
1086 let mut entries: Vec<_> = frame.extra_metadata.iter().collect();
1087 entries.sort_by(|a, b| a.0.cmp(b.0));
1088 println!("Extra metadata:");
1089 for (key, value) in entries {
1090 println!(" {key}: {value}");
1091 }
1092 }
1093 if !frame.content_dates.is_empty() {
1094 println!("Content dates: {}", frame.content_dates.join(", "));
1095 }
1096 match mem.frame_embedding(frame.id) {
1097 Ok(Some(embedding)) => println!("Embedding: {} dimensions", embedding.len()),
1098 Ok(None) => println!("Embedding: none"),
1099 Err(err) => println!("Embedding: unavailable ({err})"),
1100 }
1101 Ok(())
1102}
1103fn canonical_text_for_view(mem: &mut Memvid, frame: &Frame) -> Result<String> {
1104 let bytes = mem.frame_canonical_payload(frame.id)?;
1105 let raw = match String::from_utf8(bytes) {
1106 Ok(text) => text,
1107 Err(err) => {
1108 let bytes = err.into_bytes();
1109 String::from_utf8_lossy(&bytes).into_owned()
1110 }
1111 };
1112
1113 Ok(normalize_text(&raw, usize::MAX)
1114 .map(|n| n.text)
1115 .unwrap_or_default())
1116}
1117
1118fn manifests_match_text(text: &str, manifest: &TextChunkManifest) -> bool {
1119 if manifest.chunk_chars == 0 || manifest.chunks.is_empty() {
1120 return false;
1121 }
1122 let total_chars = text.chars().count();
1123 manifest
1124 .chunks
1125 .iter()
1126 .all(|chunk| chunk.start <= chunk.end && chunk.end <= total_chars)
1127}
1128
1129fn canonical_manifest_from_frame(text: &str, frame: &Frame) -> Option<TextChunkManifest> {
1130 let primary = frame
1131 .chunk_manifest
1132 .clone()
1133 .filter(|manifest| manifests_match_text(text, manifest));
1134 if primary.is_some() {
1135 return primary;
1136 }
1137
1138 frame
1139 .extra_metadata
1140 .get(CHUNK_MANIFEST_KEY)
1141 .and_then(|raw| serde_json::from_str::<TextChunkManifest>(raw).ok())
1142 .filter(|manifest| manifests_match_text(text, manifest))
1143}
1144
1145fn compute_chunk_manifest(text: &str, chunk_chars: usize) -> TextChunkManifest {
1146 let normalized = normalize_text(text, usize::MAX)
1147 .map(|n| n.text)
1148 .unwrap_or_default();
1149
1150 let effective_chunk = chunk_chars.max(1);
1151 let total_chars = normalized.chars().count();
1152 if total_chars == 0 {
1153 return TextChunkManifest {
1154 chunk_chars: effective_chunk,
1155 chunks: vec![TextChunkRange { start: 0, end: 0 }],
1156 };
1157 }
1158 if total_chars <= effective_chunk {
1159 return TextChunkManifest {
1160 chunk_chars: effective_chunk,
1161 chunks: vec![TextChunkRange {
1162 start: 0,
1163 end: total_chars,
1164 }],
1165 };
1166 }
1167 let mut chunks = Vec::new();
1168 let mut start = 0usize;
1169 while start < total_chars {
1170 let end = (start + effective_chunk).min(total_chars);
1171 chunks.push(TextChunkRange { start, end });
1172 start = end;
1173 }
1174 TextChunkManifest {
1175 chunk_chars: effective_chunk,
1176 chunks,
1177 }
1178}
1179
1180fn extract_chunk_slice(text: &str, range: &TextChunkRange) -> String {
1181 if range.start >= range.end || text.is_empty() {
1182 return String::new();
1183 }
1184 let mut start_byte = text.len();
1185 let mut end_byte = text.len();
1186 let mut idx = 0usize;
1187 for (byte_offset, _) in text.char_indices() {
1188 if idx == range.start {
1189 start_byte = byte_offset;
1190 }
1191 if idx == range.end {
1192 end_byte = byte_offset;
1193 break;
1194 }
1195 idx += 1;
1196 }
1197 if start_byte == text.len() {
1198 return String::new();
1199 }
1200 if end_byte == text.len() {
1201 end_byte = text.len();
1202 }
1203 text[start_byte..end_byte].to_string()
1204}