1#![allow(
14 clippy::many_single_char_names,
15 clippy::cast_precision_loss,
16 clippy::cast_possible_truncation,
17 clippy::cast_sign_loss
18)]
19
20pub mod clips;
21#[cfg(feature = "ffmpeg")]
22pub mod decode;
23pub mod flags;
24pub mod index;
25pub mod location;
26pub mod page;
27pub mod scene;
28pub mod search;
29pub mod snap;
30pub mod strip;
31pub mod transcribe;
32pub mod vad;
33
34pub use index::{Clip, Flag, FlagKind, Index, Span, Transcript, Word, timecode};
35pub use search::{Hit, search};
36pub use snap::{Prefer, SnapParams, snap};
37
38#[derive(Debug, thiserror::Error)]
39pub enum Error {
40 #[error(transparent)]
41 Io(#[from] std::io::Error),
42 #[error(transparent)]
43 Json(#[from] serde_json::Error),
44 #[error("decode: {0}")]
45 Decode(String),
46 #[error("whisper: {0}")]
47 Whisper(String),
48 #[error("{0}")]
49 Invalid(String),
50}
51
52pub type Result<T, E = Error> = std::result::Result<T, E>;
53
54#[cfg(feature = "ffmpeg")]
55pub use run::{Options, Summary, run};
56
57#[cfg(feature = "ffmpeg")]
58mod run {
59 use std::path::{Path, PathBuf};
60 use std::sync::Mutex;
61 use std::sync::atomic::{AtomicUsize, Ordering};
62
63 use serde::{Deserialize, Serialize};
64
65 use crate::clips::{self, ClipFile};
66 use crate::decode::{self, ScanParams};
67 use crate::flags::FlagParams;
68 use crate::index::{self, Clip, Index};
69 use crate::transcribe::{Whisper, WhisperParams};
70 use crate::vad::{self, VadParams};
71 use crate::{Result, page, strip};
72
73 #[derive(Debug, Clone)]
74 pub struct Options {
75 pub dir: PathBuf,
77 pub list: Option<PathBuf>,
79 pub no_catalog: bool,
81 pub out: PathBuf,
83 pub jobs: usize,
85 pub scan: ScanParams,
86 pub vad: VadParams,
87 pub flags: FlagParams,
88 pub immich: Option<crate::location::ImmichParams>,
90 pub whisper: Option<WhisperParams>,
92 pub min_speech: f64,
94 pub force: bool,
96 pub title: String,
98 }
99
100 #[derive(Debug, Default)]
101 pub struct Summary {
102 pub clips: usize,
103 pub analysed: usize,
104 pub transcribed: usize,
105 pub failed: Vec<(String, String)>,
106 pub index: PathBuf,
107 pub page: PathBuf,
108 }
109
110 #[derive(Serialize, Deserialize)]
111 struct Cached {
112 version: u32,
113 clip: Clip,
114 }
115
116 fn mtime(p: &Path) -> Result<(u64, i64)> {
117 let m = std::fs::metadata(p)?;
118 let t = m
119 .modified()?
120 .duration_since(std::time::UNIX_EPOCH)
121 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX));
122 Ok((m.len(), t))
123 }
124
125 fn load_cached(path: &Path, f: &ClipFile, size: u64, mt: i64) -> Option<Clip> {
126 let c: Cached = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
127 (c.version == index::VERSION
128 && c.clip.size == size
129 && c.clip.mtime == mt
130 && c.clip.analysed == f.analysed())
131 .then_some(c.clip)
132 }
133
134 fn save_cached(path: &Path, clip: &Clip) -> Result<()> {
135 index::write_json(
136 path,
137 &Cached {
138 version: index::VERSION,
139 clip: clip.clone(),
140 },
141 )
142 }
143
144 fn analyse(f: &ClipFile, o: &Options, pcm: Option<&Path>, size: u64, mt: i64) -> Result<Clip> {
145 let analysed = f.analysed();
146 let use_gpu = o.scan.gpu && f.proxy.is_none();
147 let v = decode::scan_video(analysed, use_gpu, &o.scan)?;
148 let (width, height, fps) = if f.proxy.is_some() {
149 decode::probe_video(&f.source).unwrap_or((v.width, v.height, v.fps))
150 } else {
151 (v.width, v.height, v.fps)
152 };
153 let strip_name = format!("strips/{}.jpg", f.id);
154 let frames: Vec<strip::Frame<'_>> = v
155 .thumbs
156 .iter()
157 .map(|t| strip::Frame {
158 w: t.w,
159 h: t.h,
160 rgb: &t.rgb,
161 })
162 .collect();
163 let strip = if frames.is_empty() {
164 None
165 } else {
166 strip::write_strip(&o.out.join(&strip_name), &frames)?;
167 Some(strip_name)
168 };
169 let audio = decode::scan_audio(analysed, pcm)?;
170 let recorded = f
171 .local
172 .clone()
173 .or_else(|| clips::dji_name(&f.id).map(|(t, _)| t));
174 Ok(Clip {
175 id: f.id.clone(),
176 source: f.source.clone(),
177 analysed: analysed.to_owned(),
178 size,
179 mtime: mt,
180 duration: (v.duration * 1000.0).round() / 1000.0,
181 fps: (fps * 1000.0).round() / 1000.0,
182 width: Some(width),
183 height: Some(height),
184 recorded,
185 utc: f.utc.clone(),
186 take: None,
187 strip,
188 strip_times: v
189 .thumbs
190 .iter()
191 .map(|t| (t.t * 100.0).round() / 100.0)
192 .collect(),
193 cuts: v.cuts,
194 activity: v.activity,
195 luma: v.luma,
196 has_audio: audio.is_some(),
197 speech: audio
198 .map(|a| vad::speech(&a.db, &o.vad))
199 .unwrap_or_default(),
200 transcript: None,
201 flags: Vec::new(),
202 location: None,
203 })
204 }
205
206 #[allow(clippy::too_many_lines)] pub fn run(o: &Options) -> Result<Summary> {
208 decode::init()?;
209 let catalog = if o.list.is_none() && !o.no_catalog {
210 clips::from_catalog(&o.dir)?
211 } else {
212 None
213 };
214 let files = match (&o.list, catalog) {
215 (Some(l), _) => clips::from_list(l)?,
216 (None, Some(c)) => {
217 eprintln!("{} clips from the ingest catalog", c.len());
218 c
219 }
220 (None, None) => clips::discover(&o.dir, &[o.out.as_path()])?,
221 };
222 let cache_dir = o.out.join("clips");
223 let scratch = o.out.join(".scratch");
224 for d in [&o.out, &o.out.join("strips"), &cache_dir, &scratch] {
225 std::fs::create_dir_all(d)?;
226 }
227 let n = files.len();
228 let slots: Vec<Mutex<Option<Clip>>> = (0..n).map(|_| Mutex::new(None)).collect();
229 let failed = Mutex::new(Vec::new());
230 let next = AtomicUsize::new(0);
231 let done = AtomicUsize::new(0);
232 let analysed = AtomicUsize::new(0);
233 let pcm_path = |id: &str| scratch.join(format!("{id}.f32"));
234 let want_pcm = o.whisper.is_some();
235
236 std::thread::scope(|s| {
237 for _ in 0..o.jobs.max(1) {
238 s.spawn(|| {
239 loop {
240 let i = next.fetch_add(1, Ordering::Relaxed);
241 let Some(f) = files.get(i) else { break };
242 let cache = cache_dir.join(format!("{}.json", f.id));
243 let r = mtime(&f.source).and_then(|(size, mt)| {
244 if !o.force
245 && let Some(c) = load_cached(&cache, f, size, mt)
246 {
247 return Ok(c);
248 }
249 let pcm = want_pcm.then(|| pcm_path(&f.id));
250 let c = analyse(f, o, pcm.as_deref(), size, mt)?;
251 save_cached(&cache, &c)?;
252 analysed.fetch_add(1, Ordering::Relaxed);
253 Ok(c)
254 });
255 let k = done.fetch_add(1, Ordering::Relaxed) + 1;
256 match r {
257 Ok(c) => {
258 eprintln!(
259 "[{k}/{n}] {} {} cuts {} speech {:.1} s",
260 c.id,
261 index::timecode(c.duration),
262 c.cuts.len(),
263 c.speech_seconds()
264 );
265 if let Ok(mut slot) = slots[i].lock() {
266 *slot = Some(c);
267 }
268 }
269 Err(e) => {
270 eprintln!("[{k}/{n}] {}: {e}", f.id);
271 if let Ok(mut fl) = failed.lock() {
272 fl.push((f.id.clone(), e.to_string()));
273 }
274 }
275 }
276 }
277 });
278 }
279 });
280
281 let mut clips: Vec<Clip> = slots
282 .into_iter()
283 .filter_map(|m| m.into_inner().ok().flatten())
284 .collect();
285 let mut failed = failed.into_inner().unwrap_or_default();
286
287 let keys: Vec<_> = clips
289 .iter()
290 .map(|c| (c.id.clone(), c.recorded.clone(), c.duration))
291 .collect();
292 let takes = clips::takes(&keys, 3.0);
293 for (c, t) in clips.iter_mut().zip(&takes) {
294 c.take =
295 (*t != c.id || takes.iter().filter(|x| *x == t).count() > 1).then(|| t.clone());
296 }
297 let take_len = |t: &Option<String>, own: f64| {
298 t.as_ref().map_or(own, |t| {
299 clips
300 .iter()
301 .filter(|c| c.take.as_ref() == Some(t))
302 .map(|c| c.duration)
303 .sum()
304 })
305 };
306 let lens: Vec<f64> = clips
307 .iter()
308 .map(|c| take_len(&c.take, c.duration))
309 .collect();
310 for (c, len) in clips.iter_mut().zip(lens) {
311 c.flags = crate::flags::flags(c, len, &o.flags);
312 }
313
314 if let Some(ip) = &o.immich {
315 let before: Vec<bool> = clips.iter().map(|c| c.location.is_some()).collect();
316 match crate::location::locate(&mut clips, ip) {
317 Ok(n) => eprintln!("located {n} clips"),
318 Err(e) => eprintln!("no locations: {e}"),
319 }
320 for (c, had) in clips.iter().zip(before) {
321 if !had && c.location.is_some() {
322 save_cached(&cache_dir.join(format!("{}.json", c.id)), c)?;
323 }
324 }
325 }
326
327 let mut transcribed = 0;
328 if let Some(wp) = &o.whisper {
329 let todo: Vec<usize> = (0..clips.len())
330 .filter(|&i| {
331 clips[i].transcript.is_none() && clips[i].speech_seconds() >= o.min_speech
332 })
333 .collect();
334 if !todo.is_empty() {
335 eprintln!(
336 "transcribing {} clips ({}, {})",
337 todo.len(),
338 wp.model,
339 wp.device
340 );
341 match Whisper::start(wp, &scratch) {
342 Ok(mut w) => {
343 for (k, &i) in todo.iter().enumerate() {
344 let c = &mut clips[i];
345 let pcm = pcm_path(&c.id);
346 let r = (if pcm.is_file() {
347 Ok(())
348 } else {
349 decode::scan_audio(&c.analysed, Some(&pcm)).map(drop)
350 })
351 .and_then(|()| w.transcribe(&c.id, &pcm));
352 match r {
353 Ok(t) => {
354 eprintln!(
355 "[{}/{}] {} {}: {} words",
356 k + 1,
357 todo.len(),
358 c.id,
359 t.language,
360 t.words().count()
361 );
362 c.transcript = Some(t);
363 save_cached(&cache_dir.join(format!("{}.json", c.id)), c)?;
364 transcribed += 1;
365 let _ = std::fs::remove_file(&pcm);
366 }
367 Err(e) => {
368 eprintln!("{}: {e}", c.id);
369 failed.push((c.id.clone(), e.to_string()));
370 if matches!(e, crate::Error::Whisper(ref m) if m.starts_with("worker exited"))
371 {
372 break;
373 }
374 }
375 }
376 }
377 }
378 Err(e) => {
379 eprintln!("no transcripts: {e}");
380 failed.push(("whisper".into(), e.to_string()));
381 }
382 }
383 }
384 }
385 let _ = std::fs::remove_dir_all(&scratch);
386
387 let index = Index {
388 version: index::VERSION,
389 root: o.dir.clone(),
390 clips,
391 };
392 let index_path = o.out.join("index.json");
393 index.save(&index_path)?;
394 let page_path = o.out.join("index.html");
395 std::fs::write(&page_path, page::render(&index, &o.title)?)?;
396 Ok(Summary {
397 clips: index.clips.len(),
398 analysed: analysed.into_inner(),
399 transcribed,
400 failed,
401 index: index_path,
402 page: page_path,
403 })
404 }
405}