1use serde::de::DeserializeOwned;
16
17use crate::segments::Segment;
18
19use super::{ConvertError, Converter, Outcome, normalize_text};
20
21pub const PDF_SUBPROCESS_ID: &str = "pdf-subprocess";
23pub const PDF_SUBPROCESS_VERSION: &str = "1.0.0";
25
26pub const OCR_ADAPTER_ID: &str = "ocr-adapter";
28pub const OCR_ADAPTER_VERSION: &str = "1.0.0";
30
31pub const ASR_ADAPTER_ID: &str = "asr-adapter";
33pub const ASR_ADAPTER_VERSION: &str = "1.0.0";
35
36pub const VIDEO_ADAPTER_ID: &str = "video-adapter";
38pub const VIDEO_ADAPTER_VERSION: &str = "1.0.0";
40
41pub const OCR_CONFIDENCE_WARNING: f64 = 0.5;
43
44pub fn format_timecode(seconds: f64) -> Result<String, String> {
47 if !seconds.is_finite() || seconds < 0.0 {
48 return Err(format!("timestamp {seconds} is not a non-negative time"));
49 }
50 let whole = seconds as u64;
51 Ok(format!(
52 "{:02}:{:02}:{:02}",
53 whole / 3600,
54 (whole % 3600) / 60,
55 whole % 60
56 ))
57}
58
59fn single_line(text: &str) -> String {
60 text.replace(['\n', '\r'], " ").trim().to_string()
61}
62
63#[cfg(unix)]
64mod imp {
65 use std::sync::OnceLock;
66
67 use super::*;
68 use crate::runner::protocol::bodies::{
69 AsrOk, AsrRequest, OcrInput, OcrOk, OcrRequest, PdfOk, PdfRequest, VideoOk, VideoRequest,
70 };
71 use crate::runner::{Runner, RunnerError};
72
73 fn static_code(code: &str) -> &'static str {
78 const KNOWN: &[&str] = &[
79 "pdf_no_text_layer",
80 "malformed",
81 "encrypted",
82 "resourceLimit",
83 "missingPart",
84 "unsupported",
85 "io",
86 "empty_output",
87 "invalid_utf8",
88 "converter_panic",
89 "unclaimed_format",
90 "resource_limit",
91 ];
92 KNOWN
93 .iter()
94 .find(|known| **known == code)
95 .copied()
96 .unwrap_or("adapter_error")
97 }
98
99 fn runner_failure(error: RunnerError) -> ConvertError {
100 ConvertError {
101 code: error.code,
102 message: error.message,
103 }
104 }
105
106 fn call<Req: serde::Serialize, Ok: DeserializeOwned>(
108 runner: &Runner,
109 mode: &str,
110 request: &Req,
111 files: &[(&str, &[u8])],
112 ) -> Result<Ok, ConvertError> {
113 let payload = serde_json::to_value(request).map_err(|e| ConvertError {
114 code: "adapter_error",
115 message: format!("cannot encode the adapter request: {e}"),
116 })?;
117 let response = runner.run(mode, payload, files).map_err(runner_failure)?;
118 if let Some(error) = response.error {
119 return Err(ConvertError {
120 code: static_code(&error.code),
121 message: error.message,
122 });
123 }
124 let ok = response.ok.expect("validated response has a body");
125 serde_json::from_value(ok).map_err(|e| ConvertError {
126 code: "adapter_error",
127 message: format!("adapter success body did not parse: {e}"),
128 })
129 }
130
131 fn shared_runner() -> Result<&'static Runner, ConvertError> {
132 static RUNNER: OnceLock<Result<Runner, RunnerError>> = OnceLock::new();
133 match RUNNER.get_or_init(Runner::with_platform_defaults) {
134 Ok(runner) => Ok(runner),
135 Err(error) => Err(runner_failure(error.clone())),
136 }
137 }
138
139 pub struct PdfSubprocess;
145
146 impl Converter for PdfSubprocess {
147 fn id(&self) -> &'static str {
148 PDF_SUBPROCESS_ID
149 }
150
151 fn version(&self) -> &'static str {
152 PDF_SUBPROCESS_VERSION
153 }
154
155 fn convert(
156 &self,
157 source: &[u8],
158 detected_format: &str,
159 ) -> std::result::Result<Outcome, ConvertError> {
160 if detected_format != "pdf" {
161 return Err(ConvertError {
162 code: "unclaimed_format",
163 message: format!(
164 "the subprocess PDF adapter does not handle {detected_format}"
165 ),
166 });
167 }
168 let body: PdfOk = call(
169 shared_runner()?,
170 "pdf",
171 &PdfRequest {
172 input: "input.pdf".to_string(),
173 },
174 &[("input.pdf", source)],
175 )?;
176 Ok(Outcome {
177 converter_id: PDF_SUBPROCESS_ID.to_string(),
178 converter_version: PDF_SUBPROCESS_VERSION.to_string(),
179 detected_format: detected_format.to_string(),
180 text: body.text,
181 warnings: body.warnings,
182 segments: body.segments,
183 })
184 }
185 }
186
187 pub struct OcrAdapter {
190 runner: Runner,
191 }
192
193 impl OcrAdapter {
194 pub fn new(runner: Runner) -> OcrAdapter {
196 OcrAdapter { runner }
197 }
198 }
199
200 impl Converter for OcrAdapter {
201 fn id(&self) -> &'static str {
202 OCR_ADAPTER_ID
203 }
204
205 fn version(&self) -> &'static str {
206 OCR_ADAPTER_VERSION
207 }
208
209 fn convert(
210 &self,
211 source: &[u8],
212 detected_format: &str,
213 ) -> std::result::Result<Outcome, ConvertError> {
214 if !matches!(detected_format, "png" | "jpeg" | "tiff") {
215 return Err(ConvertError {
216 code: "unclaimed_format",
217 message: format!("the OCR adapter does not handle {detected_format}"),
218 });
219 }
220 let input = format!("input.{detected_format}");
221 let body: OcrOk = call(
222 &self.runner,
223 "ocr",
224 &OcrRequest {
225 input: input.clone(),
226 kind: OcrInput::Image,
227 },
228 &[(&input, source)],
229 )?;
230 let mut warnings = Vec::new();
231 let mut lines = Vec::new();
232 for (index, span) in body.spans.iter().enumerate() {
233 if !(0.0..=1.0).contains(&span.confidence) {
234 return Err(ConvertError {
235 code: "adapter_error",
236 message: format!(
237 "span {index} confidence {} is outside 0 to 1",
238 span.confidence
239 ),
240 });
241 }
242 if span.confidence < OCR_CONFIDENCE_WARNING {
243 warnings.push(format!(
244 "ocr_low_confidence: span {index} at {:.2}",
245 span.confidence
246 ));
247 }
248 lines.push(single_line(&span.text));
249 }
250 let text = normalize_text(&finish_lines(lines));
251 if !source.is_empty() && text.is_empty() {
252 return Err(empty_output(source.len()));
253 }
254 Ok(Outcome {
255 converter_id: OCR_ADAPTER_ID.to_string(),
256 converter_version: OCR_ADAPTER_VERSION.to_string(),
257 detected_format: detected_format.to_string(),
258 segments: vec![Segment::span(0, text.len(), "document")],
259 text,
260 warnings,
261 })
262 }
263 }
264
265 pub struct AsrAdapter {
267 runner: Runner,
268 }
269
270 impl AsrAdapter {
271 pub fn new(runner: Runner) -> AsrAdapter {
273 AsrAdapter { runner }
274 }
275 }
276
277 impl Converter for AsrAdapter {
278 fn id(&self) -> &'static str {
279 ASR_ADAPTER_ID
280 }
281
282 fn version(&self) -> &'static str {
283 ASR_ADAPTER_VERSION
284 }
285
286 fn convert(
287 &self,
288 source: &[u8],
289 detected_format: &str,
290 ) -> std::result::Result<Outcome, ConvertError> {
291 if !matches!(detected_format, "mp3" | "wav" | "m4a") {
292 return Err(ConvertError {
293 code: "unclaimed_format",
294 message: format!("the ASR adapter does not handle {detected_format}"),
295 });
296 }
297 let input = format!("input.{detected_format}");
298 let body: AsrOk = call(
299 &self.runner,
300 "asr",
301 &AsrRequest {
302 input: input.clone(),
303 language: None,
304 },
305 &[(&input, source)],
306 )?;
307 let text = normalize_text(&render_transcript(&body.segments).map_err(|detail| {
308 ConvertError {
309 code: "adapter_error",
310 message: detail,
311 }
312 })?);
313 if !source.is_empty() && text.is_empty() {
314 return Err(empty_output(source.len()));
315 }
316 Ok(Outcome {
317 converter_id: ASR_ADAPTER_ID.to_string(),
318 converter_version: ASR_ADAPTER_VERSION.to_string(),
319 detected_format: detected_format.to_string(),
320 segments: vec![Segment::span(0, text.len(), "document")],
321 text,
322 warnings: Vec::new(),
323 })
324 }
325 }
326
327 pub struct VideoAdapter {
329 runner: Runner,
330 }
331
332 impl VideoAdapter {
333 pub fn new(runner: Runner) -> VideoAdapter {
335 VideoAdapter { runner }
336 }
337 }
338
339 impl Converter for VideoAdapter {
340 fn id(&self) -> &'static str {
341 VIDEO_ADAPTER_ID
342 }
343
344 fn version(&self) -> &'static str {
345 VIDEO_ADAPTER_VERSION
346 }
347
348 fn convert(
349 &self,
350 source: &[u8],
351 detected_format: &str,
352 ) -> std::result::Result<Outcome, ConvertError> {
353 if !matches!(detected_format, "mp4" | "mov") {
354 return Err(ConvertError {
355 code: "unclaimed_format",
356 message: format!("the video adapter does not handle {detected_format}"),
357 });
358 }
359 let input = format!("input.{detected_format}");
360 let body: VideoOk = call(
361 &self.runner,
362 "video",
363 &VideoRequest {
364 input: input.clone(),
365 sample_interval_seconds: 1.0,
366 max_frames: 10_000,
367 },
368 &[(&input, source)],
369 )?;
370 let text = normalize_text(&render_screen_states(&body.states).map_err(|detail| {
371 ConvertError {
372 code: "adapter_error",
373 message: detail,
374 }
375 })?);
376 if !source.is_empty() && text.is_empty() {
377 return Err(empty_output(source.len()));
378 }
379 Ok(Outcome {
380 converter_id: VIDEO_ADAPTER_ID.to_string(),
381 converter_version: VIDEO_ADAPTER_VERSION.to_string(),
382 detected_format: detected_format.to_string(),
383 segments: vec![Segment::span(0, text.len(), "document")],
384 text,
385 warnings: Vec::new(),
386 })
387 }
388 }
389
390 fn empty_output(source_len: usize) -> ConvertError {
391 ConvertError {
392 code: "empty_output",
393 message: format!("source is {source_len} bytes but conversion produced no text"),
394 }
395 }
396}
397
398#[cfg(unix)]
399pub use imp::{AsrAdapter, OcrAdapter, PdfSubprocess, VideoAdapter};
400
401#[cfg(not(unix))]
402mod imp {
403 use super::*;
404
405 pub struct PdfSubprocess;
408
409 impl Converter for PdfSubprocess {
410 fn id(&self) -> &'static str {
411 PDF_SUBPROCESS_ID
412 }
413
414 fn version(&self) -> &'static str {
415 PDF_SUBPROCESS_VERSION
416 }
417
418 fn convert(
419 &self,
420 _source: &[u8],
421 _detected_format: &str,
422 ) -> std::result::Result<Outcome, ConvertError> {
423 Err(ConvertError {
424 code: "sandbox_unavailable",
425 message: "no jail backend exists for this platform, refusing to run adapters"
426 .to_string(),
427 })
428 }
429 }
430}
431
432#[cfg(not(unix))]
433pub use imp::PdfSubprocess;
434
435pub fn render_transcript(segments: &[crate::runner_bodies::AsrSegment]) -> Result<String, String> {
439 let mut ordered: Vec<_> = segments.iter().collect();
440 ordered.sort_by(|a, b| {
441 a.start_seconds
442 .total_cmp(&b.start_seconds)
443 .then(a.end_seconds.total_cmp(&b.end_seconds))
444 .then(a.speaker.cmp(&b.speaker))
445 });
446 let mut lines = Vec::new();
447 for segment in ordered {
448 if segment.end_seconds < segment.start_seconds {
449 return Err(format!(
450 "segment ends at {} before it starts at {}",
451 segment.end_seconds, segment.start_seconds
452 ));
453 }
454 lines.push(format!(
455 "[{} -> {}] Speaker {}: {}",
456 format_timecode(segment.start_seconds)?,
457 format_timecode(segment.end_seconds)?,
458 segment.speaker,
459 single_line(&segment.text)
460 ));
461 }
462 Ok(finish_lines(lines))
463}
464
465pub fn render_screen_states(
469 states: &[crate::runner_bodies::ScreenState],
470) -> Result<String, String> {
471 let mut ordered: Vec<_> = states.iter().collect();
472 ordered.sort_by(|a, b| {
473 a.first_seen_seconds
474 .total_cmp(&b.first_seen_seconds)
475 .then(a.text.cmp(&b.text))
476 });
477 let mut lines = Vec::new();
478 let mut previous: Option<String> = None;
479 for state in ordered {
480 let text = single_line(&state.text);
481 if previous.as_deref() == Some(text.as_str()) {
482 continue;
483 }
484 lines.push(format!(
485 "[on-screen @ {}] {}",
486 format_timecode(state.first_seen_seconds)?,
487 text
488 ));
489 previous = Some(text);
490 }
491 Ok(finish_lines(lines))
492}
493
494fn finish_lines(lines: Vec<String>) -> String {
495 if lines.is_empty() {
496 String::new()
497 } else {
498 let mut text = lines.join("\n");
499 text.push('\n');
500 text
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::runner_bodies::{AsrSegment, ScreenState};
508
509 #[test]
510 fn timecodes_render_as_hours_minutes_seconds() {
511 assert_eq!(format_timecode(0.0).unwrap(), "00:00:00");
512 assert_eq!(format_timecode(12.9).unwrap(), "00:00:12");
513 assert_eq!(format_timecode(182.0).unwrap(), "00:03:02");
514 assert_eq!(format_timecode(3661.0).unwrap(), "01:01:01");
515 assert!(format_timecode(-1.0).is_err());
516 assert!(format_timecode(f64::NAN).is_err());
517 }
518
519 #[test]
520 fn transcripts_render_one_ordered_line_per_segment() {
521 let segments = vec![
522 AsrSegment {
523 start_seconds: 19.0,
524 end_seconds: 24.0,
525 speaker: 2,
526 text: "second\nline".to_string(),
527 },
528 AsrSegment {
529 start_seconds: 12.0,
530 end_seconds: 19.0,
531 speaker: 1,
532 text: "first remark".to_string(),
533 },
534 ];
535 assert_eq!(
536 render_transcript(&segments).unwrap(),
537 "[00:00:12 -> 00:00:19] Speaker 1: first remark\n\
538 [00:00:19 -> 00:00:24] Speaker 2: second line\n"
539 );
540 }
541
542 #[test]
543 fn transcripts_reject_a_segment_that_ends_before_it_starts() {
544 let segments = vec![AsrSegment {
545 start_seconds: 5.0,
546 end_seconds: 4.0,
547 speaker: 1,
548 text: "impossible".to_string(),
549 }];
550 assert!(render_transcript(&segments).is_err());
551 }
552
553 #[test]
554 fn screen_states_render_deduplicated_in_order() {
555 let states = vec![
556 ScreenState {
557 first_seen_seconds: 182.0,
558 text: "Quarterly revenue".to_string(),
559 },
560 ScreenState {
561 first_seen_seconds: 10.0,
562 text: "Agenda".to_string(),
563 },
564 ScreenState {
565 first_seen_seconds: 200.0,
566 text: "Quarterly revenue".to_string(),
567 },
568 ];
569 assert_eq!(
570 render_screen_states(&states).unwrap(),
571 "[on-screen @ 00:00:10] Agenda\n[on-screen @ 00:03:02] Quarterly revenue\n"
572 );
573 }
574}