Skip to main content

text_mirror/convert/
subprocess.rs

1//! Subprocess adapters: converters that run behind the sandboxed
2//! runner instead of in this process.
3//!
4//! Each adapter speaks the framed stdin/stdout protocol to a worker
5//! mode and renders the response to the outcome shape every converter
6//! emits. The PDF adapter is the production occupant: it carries the
7//! same outcome, warnings, and failure reasons as the in-process PDF
8//! path did, so its manifest records differ only in converter id. The
9//! OCR, ASR, and video adapters are the protocol half of their
10//! conversions. Their engines are not pinned yet, so the registry
11//! routes their formats to `unsupported` with reason `engine-unpinned`
12//! until a rules bump pins an engine, and the adapters are exercised
13//! against fake engines under test.
14
15use serde::de::DeserializeOwned;
16
17use crate::segments::Segment;
18
19use super::{ConvertError, Converter, Outcome, normalize_text};
20
21/// Registry id of the subprocess PDF adapter.
22pub const PDF_SUBPROCESS_ID: &str = "pdf-subprocess";
23/// Version of the subprocess PDF adapter.
24pub const PDF_SUBPROCESS_VERSION: &str = "1.0.0";
25
26/// Manifest converter id for the direct-extraction recovery path: a
27/// PDF whose text was recovered after the markdown path declined the
28/// page as image-based. It names the actual extraction path so a
29/// recovered document is never recorded under the markdown path's id.
30pub const PDF_RECOVERED_ID: &str = "pdf-text-recovered";
31/// Version of the PDF recovery path. It shares the adapter version
32/// deliberately: both paths ship in the same adapter, and the checkpoint
33/// key compares converter version, so an unchanged recovered PDF skips
34/// on a later run only because this equals the adapter version.
35///
36/// INVARIANT: any future change to the recovery path's behavior MUST
37/// bump the shared adapter version [`PDF_SUBPROCESS_VERSION`]. Because
38/// this equals that version, a bump moves both paths together and
39/// reconverts prior artifacts of both. The equality is asserted in the
40/// tests below so a silent divergence cannot compile past review.
41pub const PDF_RECOVERED_VERSION: &str = PDF_SUBPROCESS_VERSION;
42
43/// The manifest converter id and version for a PDF outcome, chosen by
44/// whether the text came from the direct recovery extractor. The
45/// registered converter keeps its single id and version; only the
46/// per-outcome manifest record names the recovery path.
47#[cfg_attr(not(unix), allow(dead_code))]
48pub(crate) fn pdf_converter_identity(recovered: bool) -> (&'static str, &'static str) {
49    if recovered {
50        (PDF_RECOVERED_ID, PDF_RECOVERED_VERSION)
51    } else {
52        (PDF_SUBPROCESS_ID, PDF_SUBPROCESS_VERSION)
53    }
54}
55
56/// Registry id of the jailed records worker adapter.
57pub const RECORDS_SUBPROCESS_ID: &str = "records-worker";
58/// Version of the jailed records worker adapter.
59pub const RECORDS_SUBPROCESS_VERSION: &str = "1.0.0";
60
61/// Registry id of the in-jail pixel-OCR converter for raster images.
62pub const IMAGE_PIXEL_OCR_ID: &str = "image-pixel-ocr";
63/// Version of the in-jail pixel-OCR converter.
64pub const IMAGE_PIXEL_OCR_VERSION: &str = "1.0.0";
65
66/// Registry id of the OCR adapter.
67pub const OCR_ADAPTER_ID: &str = "ocr-adapter";
68/// Version of the OCR adapter.
69pub const OCR_ADAPTER_VERSION: &str = "1.0.0";
70
71/// Registry id of the speech transcription adapter.
72pub const ASR_ADAPTER_ID: &str = "asr-adapter";
73/// Version of the speech transcription adapter.
74pub const ASR_ADAPTER_VERSION: &str = "1.0.0";
75
76/// Registry id of the video adapter.
77pub const VIDEO_ADAPTER_ID: &str = "video-adapter";
78/// Version of the video adapter.
79pub const VIDEO_ADAPTER_VERSION: &str = "1.0.0";
80
81/// OCR spans below this confidence carry a warning.
82pub const OCR_CONFIDENCE_WARNING: f64 = 0.5;
83
84/// Renders whole seconds as `HH:MM:SS` for transcript timecodes.
85/// The input must be finite and non-negative.
86pub fn format_timecode(seconds: f64) -> Result<String, String> {
87    if !seconds.is_finite() || seconds < 0.0 {
88        return Err(format!("timestamp {seconds} is not a non-negative time"));
89    }
90    let whole = seconds as u64;
91    Ok(format!(
92        "{:02}:{:02}:{:02}",
93        whole / 3600,
94        (whole % 3600) / 60,
95        whole % 60
96    ))
97}
98
99fn single_line(text: &str) -> String {
100    text.replace(['\n', '\r'], " ").trim().to_string()
101}
102
103#[cfg(unix)]
104mod imp {
105    use std::sync::OnceLock;
106
107    use super::*;
108    use crate::convert::RecordsLimits;
109    use crate::manifest::ArtifactKind;
110    use crate::runner::protocol::bodies::{
111        AsrOk, AsrRequest, OcrInput, OcrOk, OcrRequest, PdfOk, PdfRequest, RecordsOk,
112        RecordsRequest, VideoOk, VideoRequest,
113    };
114    use crate::runner::{Runner, RunnerError};
115
116    /// Maps a wire reason to the static reason vocabulary. Codes the
117    /// in-process converters already use pass through unchanged, so a
118    /// record's failure reason does not depend on which side of the
119    /// process boundary produced it.
120    fn static_code(code: &str) -> &'static str {
121        const KNOWN: &[&str] = &[
122            "pdf_no_text_layer",
123            "malformed",
124            "encrypted",
125            "resourceLimit",
126            "missingPart",
127            "unsupported",
128            "io",
129            "empty_output",
130            "invalid_utf8",
131            "converter_panic",
132            "unclaimed_format",
133            "resource_limit",
134            "record-limit-exceeded",
135            "records_read_error",
136            "not_sqlite",
137            // Image-OCR worker reasons. Each must survive the wire-to-
138            // static mapping so the manifest keeps the specific reason
139            // rather than collapsing it to adapter_error.
140            "image-ocr-decode-failed",
141            "image-ocr-area-exceeded",
142            "image-ocr-runtime-missing",
143            "image-ocr-runtime-mismatch",
144            "image-ocr-no-accelerator",
145            "ocr-protocol-error",
146            // Image-metadata worker reasons. Each must survive the wire-
147            // to-static mapping so the manifest keeps the specific reason
148            // rather than collapsing it to adapter_error.
149            "image-metadata-malformed",
150            "image-metadata-decompress-exceeded",
151            "image-metadata-xml-exceeded",
152            "image-metadata-limit-exceeded",
153            "image-metadata-unsupported",
154        ];
155        KNOWN
156            .iter()
157            .find(|known| **known == code)
158            .copied()
159            .unwrap_or("adapter_error")
160    }
161
162    fn runner_failure(error: RunnerError) -> ConvertError {
163        ConvertError {
164            code: error.code,
165            message: error.message,
166        }
167    }
168
169    /// Runs one adapter mode and returns the parsed success body.
170    fn call<Req: serde::Serialize, Ok: DeserializeOwned>(
171        runner: &Runner,
172        mode: &str,
173        request: &Req,
174        files: &[(&str, &[u8])],
175    ) -> Result<Ok, ConvertError> {
176        let payload = serde_json::to_value(request).map_err(|e| ConvertError {
177            code: "adapter_error",
178            message: format!("cannot encode the adapter request: {e}"),
179        })?;
180        let response = runner.run(mode, payload, files).map_err(runner_failure)?;
181        if let Some(error) = response.error {
182            return Err(ConvertError {
183                code: static_code(&error.code),
184                message: error.message,
185            });
186        }
187        let ok = response.ok.expect("validated response has a body");
188        serde_json::from_value(ok).map_err(|e| ConvertError {
189            code: "adapter_error",
190            message: format!("adapter success body did not parse: {e}"),
191        })
192    }
193
194    fn shared_runner() -> Result<&'static Runner, ConvertError> {
195        static RUNNER: OnceLock<Result<Runner, RunnerError>> = OnceLock::new();
196        match RUNNER.get_or_init(Runner::with_platform_defaults) {
197            Ok(runner) => Ok(runner),
198            Err(error) => Err(runner_failure(error.clone())),
199        }
200    }
201
202    /// The production PDF conversion behind the sandbox runner.
203    ///
204    /// The worker links the same anydoc path the in-process document
205    /// adapter used, so text, warnings such as `pdf_partial_text`, and
206    /// failure reasons such as `pdf_no_text_layer` are unchanged.
207    pub struct PdfSubprocess;
208
209    impl Converter for PdfSubprocess {
210        fn id(&self) -> &'static str {
211            PDF_SUBPROCESS_ID
212        }
213
214        fn version(&self) -> &'static str {
215            PDF_SUBPROCESS_VERSION
216        }
217
218        fn convert(
219            &self,
220            source: &[u8],
221            detected_format: &str,
222        ) -> std::result::Result<Outcome, ConvertError> {
223            // ai routes here too: a modern .ai is a PDF-compatible
224            // container, so the same worker path extracts its text
225            // layer with the pdf-family warnings and recovery route. A
226            // legacy PostScript-backed .ai has no PDF text layer and
227            // fails closed with a pdf-family reason.
228            if !matches!(detected_format, "pdf" | "ai") {
229                return Err(ConvertError {
230                    code: "unclaimed_format",
231                    message: format!(
232                        "the subprocess PDF adapter does not handle {detected_format}"
233                    ),
234                });
235            }
236            let body: PdfOk = call(
237                shared_runner()?,
238                "pdf",
239                &PdfRequest {
240                    input: "input.pdf".to_string(),
241                },
242                &[("input.pdf", source)],
243            )?;
244            let (converter_id, converter_version) = pdf_converter_identity(body.recovered);
245            Ok(Outcome {
246                artifact_kind: ArtifactKind::Text,
247                converter_id: converter_id.to_string(),
248                converter_version: converter_version.to_string(),
249                detected_format: detected_format.to_string(),
250                text: body.text,
251                warnings: body.warnings,
252                segments: body.segments,
253            })
254        }
255    }
256
257    /// The records worker adapter: parquet, avro, and sqlite behind
258    /// the sandbox runner.
259    ///
260    /// The three readers pull large native parser trees with CVE
261    /// history, so they never run in this process. Each source stages
262    /// into the jail and the reader runs in the worker, where a panic
263    /// on hostile bytes crashes the child and records a failed outcome
264    /// without touching the pipeline. The ceilings come from the rules
265    /// and travel in the request, and a source over any ceiling fails
266    /// closed with reason `record-limit-exceeded`.
267    pub struct RecordsSubprocess {
268        limits: RecordsLimits,
269    }
270
271    impl RecordsSubprocess {
272        /// An adapter over the rules-supplied ceilings.
273        pub fn new(limits: RecordsLimits) -> RecordsSubprocess {
274            RecordsSubprocess { limits }
275        }
276    }
277
278    impl Converter for RecordsSubprocess {
279        fn id(&self) -> &'static str {
280            RECORDS_SUBPROCESS_ID
281        }
282
283        fn version(&self) -> &'static str {
284            RECORDS_SUBPROCESS_VERSION
285        }
286
287        fn convert(
288            &self,
289            source: &[u8],
290            detected_format: &str,
291        ) -> std::result::Result<Outcome, ConvertError> {
292            if !matches!(detected_format, "parquet" | "avro" | "sqlite") {
293                return Err(ConvertError {
294                    code: "unclaimed_format",
295                    message: format!("the records worker does not handle {detected_format}"),
296                });
297            }
298            let input = format!("input.{detected_format}");
299            let body: RecordsOk = call(
300                shared_runner()?,
301                "records",
302                &RecordsRequest {
303                    input: input.clone(),
304                    format: detected_format.to_string(),
305                    max_records: self.limits.max_records,
306                    max_tables: self.limits.max_tables,
307                    max_output_bytes: self.limits.max_output_bytes,
308                },
309                &[(&input, source)],
310            )?;
311            Ok(Outcome {
312                artifact_kind: ArtifactKind::Text,
313                converter_id: RECORDS_SUBPROCESS_ID.to_string(),
314                converter_version: RECORDS_SUBPROCESS_VERSION.to_string(),
315                detected_format: detected_format.to_string(),
316                text: body.text,
317                warnings: body.warnings,
318                segments: body.segments,
319            })
320        }
321    }
322
323    /// The in-jail image-metadata converter: the derived-child leg that
324    /// lifts textual metadata out of a raster behind the same jail as the
325    /// records worker.
326    ///
327    /// The carrier and surface parsers pull decompression and expansion
328    /// surfaces a hostile image can flood, so they never run in this
329    /// process. Each source stages into the jail and the parsers run in
330    /// the worker, where a bomb or a flood times out or crashes the child
331    /// and records a failed child without touching the pipeline. The
332    /// ceilings come from the rules and travel in the request. The parent
333    /// holds no parser code: it renders the returned rows to the tabular
334    /// artifact and builds one hidden segment per row, so the output
335    /// contract and the hidden marking stay in the crate. Zero rows is
336    /// the not-applicable outcome, mapped to a distinct reason the
337    /// pipeline reads as write-nothing rather than a failure.
338    #[cfg(feature = "image-metadata")]
339    pub struct ImageMetadata {
340        limits: crate::convert::ImageMetadataLimits,
341    }
342
343    #[cfg(feature = "image-metadata")]
344    impl ImageMetadata {
345        /// An adapter over the rules-supplied metadata ceilings.
346        pub fn new(limits: crate::convert::ImageMetadataLimits) -> ImageMetadata {
347            ImageMetadata { limits }
348        }
349    }
350
351    #[cfg(feature = "image-metadata")]
352    impl Converter for ImageMetadata {
353        fn id(&self) -> &'static str {
354            crate::convert::image_metadata::IMAGE_METADATA_ID
355        }
356
357        fn version(&self) -> &'static str {
358            crate::convert::image_metadata::IMAGE_METADATA_VERSION
359        }
360
361        fn convert(
362            &self,
363            source: &[u8],
364            detected_format: &str,
365        ) -> std::result::Result<Outcome, ConvertError> {
366            use crate::runner::protocol::bodies::{ImageMetadataOk, ImageMetadataRequest};
367            if !crate::convert::image_metadata::image_metadata_applies(detected_format) {
368                return Err(ConvertError {
369                    code: "unclaimed_format",
370                    message: format!("the image-metadata worker does not read {detected_format}"),
371                });
372            }
373            let input = format!("input.{detected_format}");
374            let body: ImageMetadataOk = call(
375                shared_runner()?,
376                "image-metadata",
377                &ImageMetadataRequest {
378                    input: input.clone(),
379                    format: detected_format.to_string(),
380                    max_decompressed_bytes: self.limits.max_decompressed_bytes,
381                    max_xml_depth: self.limits.max_xml_depth,
382                    max_xml_events: self.limits.max_xml_events,
383                    max_boxes: self.limits.max_boxes,
384                    max_rows: self.limits.max_rows,
385                    max_output_bytes: self.limits.max_output_bytes,
386                },
387                &[(&input, source)],
388            )?;
389            // Zero rows is not-applicable: the image carries no textual
390            // metadata, so the parent writes nothing for this leg.
391            if body.rows.is_empty() {
392                return Err(ConvertError {
393                    code: crate::convert::image_metadata::IMAGE_METADATA_NOT_APPLICABLE,
394                    message: "the image carries no textual metadata".to_string(),
395                });
396            }
397            let (text, segments) = crate::convert::image_metadata::render_rows(&body.rows);
398            // A positive row count that renders to nothing is a fault, not
399            // a blank success, matching the other adapters' floor.
400            if text.is_empty() {
401                return Err(empty_output(source.len()));
402            }
403            Ok(Outcome {
404                artifact_kind: ArtifactKind::Text,
405                converter_id: crate::convert::image_metadata::IMAGE_METADATA_ID.to_string(),
406                converter_version: crate::convert::image_metadata::IMAGE_METADATA_VERSION
407                    .to_string(),
408                detected_format: detected_format.to_string(),
409                text,
410                warnings: Vec::new(),
411                segments,
412            })
413        }
414    }
415
416    /// The in-jail pixel-OCR converter for raster images.
417    ///
418    /// png, jpeg, and webp stage into the jail by bare name and the
419    /// worker decodes them with the pure-Rust image crate, asserts the
420    /// encoder-input area cap, re-encodes a canonical raster, and hands
421    /// it to the pinned vision engine as a hash-verified jailed child.
422    /// The parent does no image parsing: it selects the worker mode,
423    /// stages the bytes, and maps the fenced recognition to the outcome
424    /// shape with [`ArtifactKind::Ocr`]. The engine is machine-class
425    /// scoped, so the recognized text carries no cross-platform
426    /// byte-equality claim; only the pure-Rust decode does.
427    #[cfg(feature = "image-ocr")]
428    pub struct ImagePixelOcr {
429        /// The worker mode this converter drives. Production always uses
430        /// the pinned-runtime `image-ocr` mode; the test-only fake
431        /// constructor selects the harness `image-ocr-fake` mode.
432        mode: &'static str,
433        /// This instance's own runner, built from its own limits at
434        /// construction. Security ceilings are per-registry rules data,
435        /// so each converter owns its runner rather than sharing a
436        /// process-global cache whose ceilings the first caller would
437        /// fix for every later one.
438        runner: Result<Runner, RunnerError>,
439    }
440
441    #[cfg(feature = "image-ocr")]
442    impl ImagePixelOcr {
443        /// An adapter over the rules-supplied image-OCR jail profile. It
444        /// drives the production `image-ocr` worker mode, which fails
445        /// closed with `image-ocr-runtime-missing` until a deployment
446        /// wires the pinned runtime.
447        pub fn new(limits: crate::convert::ImageOcrLimits) -> ImagePixelOcr {
448            ImagePixelOcr {
449                mode: "image-ocr",
450                runner: build_image_runner(image_runner_limits(&limits)),
451            }
452        }
453
454        /// A test-only converter that drives the harness `image-ocr-fake`
455        /// worker mode instead of the pinned-runtime mode. It shares the
456        /// identical decode, area guards, and outcome mapping; only stage
457        /// 3 is the deterministic fake. The registry never constructs
458        /// this, so a release build's image converter always drives the
459        /// production mode and never ships fake recognition.
460        #[cfg(feature = "test-adapters")]
461        pub fn new_fake(limits: crate::convert::ImageOcrLimits) -> ImagePixelOcr {
462            ImagePixelOcr {
463                mode: "image-ocr-fake",
464                runner: build_image_runner(image_runner_limits(&limits)),
465            }
466        }
467    }
468
469    /// Maps the rules-supplied image-OCR profile onto the runner limits.
470    #[cfg(feature = "image-ocr")]
471    fn image_runner_limits(limits: &crate::convert::ImageOcrLimits) -> crate::runner::Limits {
472        crate::runner::Limits {
473            wall_timeout: std::time::Duration::from_secs(limits.wall_timeout_secs),
474            max_response_bytes: limits.max_response_bytes,
475            max_stderr_bytes: limits.max_stderr_bytes,
476            address_space_bytes: limits.address_space_bytes,
477            cpu_seconds: limits.cpu_seconds,
478            file_size_bytes: limits.file_size_bytes,
479            max_processes: limits.max_processes,
480        }
481    }
482
483    /// Builds one runner for the image-OCR jail profile. Called once per
484    /// converter instance, so the ceilings that travel are exactly this
485    /// registry's, never a leftover from an earlier caller.
486    #[cfg(feature = "image-ocr")]
487    fn build_image_runner(limits: crate::runner::Limits) -> Result<Runner, RunnerError> {
488        Ok(Runner::new(
489            crate::runner::jail::platform_backend()?,
490            crate::runner::locate_worker()?,
491            limits,
492        ))
493    }
494
495    #[cfg(feature = "image-ocr")]
496    impl Converter for ImagePixelOcr {
497        fn id(&self) -> &'static str {
498            IMAGE_PIXEL_OCR_ID
499        }
500
501        fn version(&self) -> &'static str {
502            IMAGE_PIXEL_OCR_VERSION
503        }
504
505        fn convert(
506            &self,
507            source: &[u8],
508            detected_format: &str,
509        ) -> std::result::Result<Outcome, ConvertError> {
510            if !matches!(detected_format, "png" | "jpeg" | "webp") {
511                return Err(ConvertError {
512                    code: "unclaimed_format",
513                    message: format!(
514                        "the image-pixel-ocr converter does not handle {detected_format}"
515                    ),
516                });
517            }
518            let runner = self
519                .runner
520                .as_ref()
521                .map_err(|e| runner_failure(e.clone()))?;
522            let input = format!("input.{detected_format}");
523            let body: OcrOk = call(
524                runner,
525                self.mode,
526                &OcrRequest {
527                    input: input.clone(),
528                    kind: OcrInput::Image,
529                },
530                &[(&input, source)],
531            )?;
532            // The worker's own notes (such as the long-edge validation
533            // note) propagate; the span mapping appends any low-confidence
534            // ones and applies the meaningful-text floor.
535            let mut warnings = body.warnings;
536            let (text, low_confidence) = render_ocr_spans(&body.spans, source.len())?;
537            warnings.extend(low_confidence);
538            Ok(Outcome {
539                converter_id: IMAGE_PIXEL_OCR_ID.to_string(),
540                converter_version: IMAGE_PIXEL_OCR_VERSION.to_string(),
541                detected_format: detected_format.to_string(),
542                artifact_kind: ArtifactKind::Ocr,
543                segments: vec![Segment::span(0, text.len(), "document")],
544                text,
545                warnings,
546            })
547        }
548    }
549
550    /// The OCR protocol adapter. Engine-unpinned: reachable only
551    /// through an explicit runner until a rules bump pins an engine.
552    pub struct OcrAdapter {
553        runner: Runner,
554    }
555
556    impl OcrAdapter {
557        /// An adapter over an explicit runner.
558        pub fn new(runner: Runner) -> OcrAdapter {
559            OcrAdapter { runner }
560        }
561    }
562
563    impl Converter for OcrAdapter {
564        fn id(&self) -> &'static str {
565            OCR_ADAPTER_ID
566        }
567
568        fn version(&self) -> &'static str {
569            OCR_ADAPTER_VERSION
570        }
571
572        fn convert(
573            &self,
574            source: &[u8],
575            detected_format: &str,
576        ) -> std::result::Result<Outcome, ConvertError> {
577            if !matches!(detected_format, "png" | "jpeg" | "tiff") {
578                return Err(ConvertError {
579                    code: "unclaimed_format",
580                    message: format!("the OCR adapter does not handle {detected_format}"),
581                });
582            }
583            let input = format!("input.{detected_format}");
584            let body: OcrOk = call(
585                &self.runner,
586                "ocr",
587                &OcrRequest {
588                    input: input.clone(),
589                    kind: OcrInput::Image,
590                },
591                &[(&input, source)],
592            )?;
593            // Propagate the worker's own notes rather than starting a
594            // fresh vec, then append the low-confidence ones.
595            let mut warnings = body.warnings;
596            let mut lines = Vec::new();
597            for (index, span) in body.spans.iter().enumerate() {
598                if !(0.0..=1.0).contains(&span.confidence) {
599                    return Err(ConvertError {
600                        code: "adapter_error",
601                        message: format!(
602                            "span {index} confidence {} is outside 0 to 1",
603                            span.confidence
604                        ),
605                    });
606                }
607                if span.confidence < OCR_CONFIDENCE_WARNING {
608                    warnings.push(format!(
609                        "ocr_low_confidence: span {index} at {:.2}",
610                        span.confidence
611                    ));
612                }
613                lines.push(single_line(&span.text));
614            }
615            let text = normalize_text(&finish_lines(lines));
616            if !source.is_empty() && text.is_empty() {
617                return Err(empty_output(source.len()));
618            }
619            Ok(Outcome {
620                // Recognized text is OCR, not extraction.
621                artifact_kind: ArtifactKind::Ocr,
622                converter_id: OCR_ADAPTER_ID.to_string(),
623                converter_version: OCR_ADAPTER_VERSION.to_string(),
624                detected_format: detected_format.to_string(),
625                segments: vec![Segment::span(0, text.len(), "document")],
626                text,
627                warnings,
628            })
629        }
630    }
631
632    /// The speech transcription protocol adapter. Engine-unpinned.
633    pub struct AsrAdapter {
634        runner: Runner,
635    }
636
637    impl AsrAdapter {
638        /// An adapter over an explicit runner.
639        pub fn new(runner: Runner) -> AsrAdapter {
640            AsrAdapter { runner }
641        }
642    }
643
644    impl Converter for AsrAdapter {
645        fn id(&self) -> &'static str {
646            ASR_ADAPTER_ID
647        }
648
649        fn version(&self) -> &'static str {
650            ASR_ADAPTER_VERSION
651        }
652
653        fn convert(
654            &self,
655            source: &[u8],
656            detected_format: &str,
657        ) -> std::result::Result<Outcome, ConvertError> {
658            if !matches!(detected_format, "mp3" | "wav" | "m4a") {
659                return Err(ConvertError {
660                    code: "unclaimed_format",
661                    message: format!("the ASR adapter does not handle {detected_format}"),
662                });
663            }
664            let input = format!("input.{detected_format}");
665            let body: AsrOk = call(
666                &self.runner,
667                "asr",
668                &AsrRequest {
669                    input: input.clone(),
670                    language: None,
671                },
672                &[(&input, source)],
673            )?;
674            let text = normalize_text(&render_transcript(&body.segments).map_err(|detail| {
675                ConvertError {
676                    code: "adapter_error",
677                    message: detail,
678                }
679            })?);
680            if !source.is_empty() && text.is_empty() {
681                return Err(empty_output(source.len()));
682            }
683            Ok(Outcome {
684                // A speech transcript, not extracted text.
685                artifact_kind: ArtifactKind::Transcript,
686                converter_id: ASR_ADAPTER_ID.to_string(),
687                converter_version: ASR_ADAPTER_VERSION.to_string(),
688                detected_format: detected_format.to_string(),
689                segments: vec![Segment::span(0, text.len(), "document")],
690                text,
691                warnings: Vec::new(),
692            })
693        }
694    }
695
696    /// The video protocol adapter. Engine-unpinned.
697    pub struct VideoAdapter {
698        runner: Runner,
699    }
700
701    impl VideoAdapter {
702        /// An adapter over an explicit runner.
703        pub fn new(runner: Runner) -> VideoAdapter {
704            VideoAdapter { runner }
705        }
706    }
707
708    impl Converter for VideoAdapter {
709        fn id(&self) -> &'static str {
710            VIDEO_ADAPTER_ID
711        }
712
713        fn version(&self) -> &'static str {
714            VIDEO_ADAPTER_VERSION
715        }
716
717        fn convert(
718            &self,
719            source: &[u8],
720            detected_format: &str,
721        ) -> std::result::Result<Outcome, ConvertError> {
722            if !matches!(detected_format, "mp4" | "mov") {
723                return Err(ConvertError {
724                    code: "unclaimed_format",
725                    message: format!("the video adapter does not handle {detected_format}"),
726                });
727            }
728            let input = format!("input.{detected_format}");
729            let body: VideoOk = call(
730                &self.runner,
731                "video",
732                &VideoRequest {
733                    input: input.clone(),
734                    sample_interval_seconds: 1.0,
735                    max_frames: 10_000,
736                },
737                &[(&input, source)],
738            )?;
739            let text = normalize_text(&render_screen_states(&body.states).map_err(|detail| {
740                ConvertError {
741                    code: "adapter_error",
742                    message: detail,
743                }
744            })?);
745            if !source.is_empty() && text.is_empty() {
746                return Err(empty_output(source.len()));
747            }
748            Ok(Outcome {
749                artifact_kind: ArtifactKind::Text,
750                converter_id: VIDEO_ADAPTER_ID.to_string(),
751                converter_version: VIDEO_ADAPTER_VERSION.to_string(),
752                detected_format: detected_format.to_string(),
753                segments: vec![Segment::span(0, text.len(), "document")],
754                text,
755                warnings: Vec::new(),
756            })
757        }
758    }
759
760    fn empty_output(source_len: usize) -> ConvertError {
761        ConvertError {
762            code: "empty_output",
763            message: format!("source is {source_len} bytes but conversion produced no text"),
764        }
765    }
766}
767
768#[cfg(all(unix, feature = "image-metadata"))]
769pub use imp::ImageMetadata;
770#[cfg(all(unix, feature = "image-ocr"))]
771pub use imp::ImagePixelOcr;
772#[cfg(unix)]
773pub use imp::{AsrAdapter, OcrAdapter, PdfSubprocess, RecordsSubprocess, VideoAdapter};
774
775#[cfg(not(unix))]
776mod imp {
777    use super::*;
778    use crate::convert::RecordsLimits;
779
780    fn no_backend() -> ConvertError {
781        ConvertError {
782            code: "sandbox_unavailable",
783            message: "no jail backend exists for this platform, refusing to run adapters"
784                .to_string(),
785        }
786    }
787
788    /// The subprocess PDF adapter on a platform without a jail
789    /// backend. It refuses every run.
790    pub struct PdfSubprocess;
791
792    impl Converter for PdfSubprocess {
793        fn id(&self) -> &'static str {
794            PDF_SUBPROCESS_ID
795        }
796
797        fn version(&self) -> &'static str {
798            PDF_SUBPROCESS_VERSION
799        }
800
801        fn convert(
802            &self,
803            _source: &[u8],
804            _detected_format: &str,
805        ) -> std::result::Result<Outcome, ConvertError> {
806            Err(no_backend())
807        }
808    }
809
810    /// The records worker adapter on a platform without a jail
811    /// backend. It refuses every run.
812    pub struct RecordsSubprocess;
813
814    impl RecordsSubprocess {
815        /// An adapter that refuses every run, ceilings unused.
816        pub fn new(_limits: RecordsLimits) -> RecordsSubprocess {
817            RecordsSubprocess
818        }
819    }
820
821    impl Converter for RecordsSubprocess {
822        fn id(&self) -> &'static str {
823            RECORDS_SUBPROCESS_ID
824        }
825
826        fn version(&self) -> &'static str {
827            RECORDS_SUBPROCESS_VERSION
828        }
829
830        fn convert(
831            &self,
832            _source: &[u8],
833            _detected_format: &str,
834        ) -> std::result::Result<Outcome, ConvertError> {
835            Err(no_backend())
836        }
837    }
838}
839
840#[cfg(not(unix))]
841pub use imp::{PdfSubprocess, RecordsSubprocess};
842
843/// Renders speech segments to the transcript format:
844/// `[HH:MM:SS -> HH:MM:SS] Speaker N: text`, one line per segment,
845/// ordered by start time.
846pub fn render_transcript(segments: &[crate::runner_bodies::AsrSegment]) -> Result<String, String> {
847    let mut ordered: Vec<_> = segments.iter().collect();
848    ordered.sort_by(|a, b| {
849        a.start_seconds
850            .total_cmp(&b.start_seconds)
851            .then(a.end_seconds.total_cmp(&b.end_seconds))
852            .then(a.speaker.cmp(&b.speaker))
853    });
854    let mut lines = Vec::new();
855    for segment in ordered {
856        if segment.end_seconds < segment.start_seconds {
857            return Err(format!(
858                "segment ends at {} before it starts at {}",
859                segment.end_seconds, segment.start_seconds
860            ));
861        }
862        lines.push(format!(
863            "[{} -> {}] Speaker {}: {}",
864            format_timecode(segment.start_seconds)?,
865            format_timecode(segment.end_seconds)?,
866            segment.speaker,
867            single_line(&segment.text)
868        ));
869    }
870    Ok(finish_lines(lines))
871}
872
873/// Renders deduplicated screen states to the transcript format:
874/// `[on-screen @ HH:MM:SS] text`, ordered by first appearance, with
875/// consecutive identical text collapsed.
876pub fn render_screen_states(
877    states: &[crate::runner_bodies::ScreenState],
878) -> Result<String, String> {
879    let mut ordered: Vec<_> = states.iter().collect();
880    ordered.sort_by(|a, b| {
881        a.first_seen_seconds
882            .total_cmp(&b.first_seen_seconds)
883            .then(a.text.cmp(&b.text))
884    });
885    let mut lines = Vec::new();
886    let mut previous: Option<String> = None;
887    for state in ordered {
888        let text = single_line(&state.text);
889        if previous.as_deref() == Some(text.as_str()) {
890            continue;
891        }
892        lines.push(format!(
893            "[on-screen @ {}] {}",
894            format_timecode(state.first_seen_seconds)?,
895            text
896        ));
897        previous = Some(text);
898    }
899    Ok(finish_lines(lines))
900}
901
902fn finish_lines(lines: Vec<String>) -> String {
903    if lines.is_empty() {
904        String::new()
905    } else {
906        let mut text = lines.join("\n");
907        text.push('\n');
908        text
909    }
910}
911
912/// Renders recognized spans to normalized text plus the low-confidence
913/// warnings, applying two floors so a well-formed but content-free
914/// recognition cannot pass as a blank success.
915///
916/// A span confidence outside 0 to 1 is a protocol error. A span under
917/// [`OCR_CONFIDENCE_WARNING`] adds one low-confidence warning. After the
918/// lines are joined and normalized, the meaningful-text floor applies:
919/// on a non-empty source, text with no meaningful character (only
920/// whitespace, control, or invisible-format code points, exactly the
921/// class the PDF recovery path gates on) fails closed with
922/// `empty_output` rather than emitting a blank artifact. This is where a
923/// well-formed but empty recognition envelope is caught: the envelope
924/// grammar keeps `ocr-protocol-error` for malformed stdout, and an
925/// empty-but-well-formed recognition lands here as `empty_output`.
926#[cfg(all(unix, feature = "image-ocr"))]
927pub(crate) fn render_ocr_spans(
928    spans: &[crate::runner::protocol::bodies::OcrSpan],
929    source_len: usize,
930) -> Result<(String, Vec<String>), ConvertError> {
931    let mut warnings = Vec::new();
932    let mut lines = Vec::new();
933    for (index, span) in spans.iter().enumerate() {
934        if !(0.0..=1.0).contains(&span.confidence) {
935            return Err(ConvertError {
936                code: "ocr-protocol-error",
937                message: format!(
938                    "span {index} confidence {} is outside 0 to 1",
939                    span.confidence
940                ),
941            });
942        }
943        if span.confidence < OCR_CONFIDENCE_WARNING {
944            warnings.push(format!(
945                "ocr_low_confidence: span {index} at {:.2}",
946                span.confidence
947            ));
948        }
949        lines.push(single_line(&span.text));
950    }
951    let text = normalize_text(&finish_lines(lines));
952    if source_len > 0 && !text.chars().any(crate::convert::is_meaningful) {
953        return Err(ConvertError {
954            code: "empty_output",
955            message: format!("source is {source_len} bytes but conversion produced no text"),
956        });
957    }
958    Ok((text, warnings))
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964    use crate::runner_bodies::{AsrSegment, ScreenState};
965
966    #[test]
967    fn recovered_pdf_text_is_recorded_under_the_recovery_id() {
968        // The markdown path keeps the adapter id; a recovered document
969        // names the recovery path, and both share the adapter version
970        // so the checkpoint key still skips an unchanged recovered PDF.
971        assert_eq!(
972            pdf_converter_identity(false),
973            (PDF_SUBPROCESS_ID, PDF_SUBPROCESS_VERSION)
974        );
975        assert_eq!(
976            pdf_converter_identity(true),
977            (PDF_RECOVERED_ID, PDF_RECOVERED_VERSION)
978        );
979        assert_ne!(PDF_RECOVERED_ID, PDF_SUBPROCESS_ID);
980        assert_eq!(PDF_RECOVERED_VERSION, PDF_SUBPROCESS_VERSION);
981    }
982
983    #[test]
984    fn timecodes_render_as_hours_minutes_seconds() {
985        assert_eq!(format_timecode(0.0).unwrap(), "00:00:00");
986        assert_eq!(format_timecode(12.9).unwrap(), "00:00:12");
987        assert_eq!(format_timecode(182.0).unwrap(), "00:03:02");
988        assert_eq!(format_timecode(3661.0).unwrap(), "01:01:01");
989        assert!(format_timecode(-1.0).is_err());
990        assert!(format_timecode(f64::NAN).is_err());
991    }
992
993    #[test]
994    fn transcripts_render_one_ordered_line_per_segment() {
995        let segments = vec![
996            AsrSegment {
997                start_seconds: 19.0,
998                end_seconds: 24.0,
999                speaker: 2,
1000                text: "second\nline".to_string(),
1001            },
1002            AsrSegment {
1003                start_seconds: 12.0,
1004                end_seconds: 19.0,
1005                speaker: 1,
1006                text: "first remark".to_string(),
1007            },
1008        ];
1009        assert_eq!(
1010            render_transcript(&segments).unwrap(),
1011            "[00:00:12 -> 00:00:19] Speaker 1: first remark\n\
1012             [00:00:19 -> 00:00:24] Speaker 2: second line\n"
1013        );
1014    }
1015
1016    #[test]
1017    fn transcripts_reject_a_segment_that_ends_before_it_starts() {
1018        let segments = vec![AsrSegment {
1019            start_seconds: 5.0,
1020            end_seconds: 4.0,
1021            speaker: 1,
1022            text: "impossible".to_string(),
1023        }];
1024        assert!(render_transcript(&segments).is_err());
1025    }
1026
1027    #[test]
1028    fn screen_states_render_deduplicated_in_order() {
1029        let states = vec![
1030            ScreenState {
1031                first_seen_seconds: 182.0,
1032                text: "Quarterly revenue".to_string(),
1033            },
1034            ScreenState {
1035                first_seen_seconds: 10.0,
1036                text: "Agenda".to_string(),
1037            },
1038            ScreenState {
1039                first_seen_seconds: 200.0,
1040                text: "Quarterly revenue".to_string(),
1041            },
1042        ];
1043        assert_eq!(
1044            render_screen_states(&states).unwrap(),
1045            "[on-screen @ 00:00:10] Agenda\n[on-screen @ 00:03:02] Quarterly revenue\n"
1046        );
1047    }
1048}