Skip to main content

ffmpeg_writer/
lib.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::{
5    collections::VecDeque,
6    io::Write,
7    process::{Child, ChildStdin, Command, Stdio},
8};
9
10use machine_vision_formats::pixel_format::PixFmt;
11
12const FFMPEG: &str = "ffmpeg";
13
14#[derive(thiserror::Error, Debug)]
15pub enum Error {
16    #[error("IO error: {0}")]
17    Io(#[from] std::io::Error),
18    #[error("ffmpeg error ({})", output.status)]
19    FfmpegError { output: std::process::Output },
20    #[error("string not valid UTF8")]
21    FromUtf8Error(#[from] std::string::FromUtf8Error),
22    #[error("unexpected ffmpeg output: {0}")]
23    UnexpectedFfmpegOutput(String),
24    #[error("the frame format or size changed mid-stream")]
25    FormatOrSizeChanged,
26    // We deliberately do not (yet) convert unsupported pixel formats to a format
27    // ffmpeg accepts; such conversions belong in the `convert-image` crate. For
28    // now, formats without a direct raw-video equivalent are unimplemented.
29    #[error("no direct ffmpeg raw-video pixel format for {0}; conversion unimplemented")]
30    UnimplementedPixelFormat(PixFmt),
31}
32
33type Result<T> = std::result::Result<T, Error>;
34
35/// The ffmpeg raw-video (`-f rawvideo`) pixel-format name corresponding to a
36/// [`PixFmt`], if the bytes can be piped to ffmpeg without any conversion on
37/// our side (ffmpeg itself does any conversion the encoder needs).
38///
39/// Returns `Err(Error::UnimplementedPixelFormat)` for formats that would
40/// require us to convert first (e.g. 32-bit float or planar formats).
41pub fn ffmpeg_pixel_format(pixfmt: PixFmt) -> Result<&'static str> {
42    use PixFmt::*;
43    // The Bayer names differ between the machine-vision-formats convention
44    // (named by the first two pixels of the first row) and ffmpeg's (named by
45    // the top-left 2x2 block): e.g. `BayerRG8` (row0 = R,G; row1 = G,B) is
46    // ffmpeg's `bayer_rggb8`.
47    Ok(match pixfmt {
48        Mono8 => "gray",
49        RGB8 => "rgb24",
50        // machine-vision-formats YUV422 is UYVY-packed ([U, Y0, V, Y1]).
51        YUV422 => "uyvy422",
52        BayerRG8 => "bayer_rggb8",
53        BayerGR8 => "bayer_grbg8",
54        BayerGB8 => "bayer_gbrg8",
55        BayerBG8 => "bayer_bggr8",
56        other => return Err(Error::UnimplementedPixelFormat(other)),
57    })
58}
59
60/// Saves video frames to a video file using ffmpeg.
61///
62/// This spawns an ffmpeg process and pipes the frames as raw video
63/// (`-f rawvideo`) with no intermediate format conversion on our side; ffmpeg
64/// performs whatever conversion the chosen encoder requires. The ffmpeg process
65/// is spawned lazily on the first frame, once the frame width, height and pixel
66/// format are known.
67pub struct FfmpegWriter {
68    fname: String,
69    ffmpeg_codec_args: FfmpegCodecArgs,
70    raten: usize,
71    rated: usize,
72    count: usize,
73    running: Option<Running>,
74}
75
76/// State of the spawned ffmpeg process, created on the first frame.
77struct Running {
78    child: Child,
79    stdin: ChildStdin,
80    pixfmt: PixFmt,
81    width: u32,
82    height: u32,
83}
84
85type FfmpegCodecArgList = Option<Vec<(String, String)>>;
86
87/// The default output pixel format. 4:2:0 chroma subsampling is the most widely
88/// decodable choice; in particular the built-in OpenH264 decoder only handles
89/// 4:2:0, so anything we might want to decode later must be encoded this way.
90/// Without forcing this, encoders like libx264 pick a format matching the input
91/// (e.g. `yuv444p` for RGB input), which OpenH264 cannot decode.
92const DEFAULT_OUTPUT_PIXFMT: &str = "yuv420p";
93
94#[derive(Debug, PartialEq, Clone)]
95pub struct FfmpegCodecArgs {
96    pub device_args: FfmpegCodecArgList,
97    pub pre_codec_args: FfmpegCodecArgList,
98    pub codec: Option<String>,
99    pub post_codec_args: FfmpegCodecArgList,
100    /// Output pixel format passed to ffmpeg as `-pix_fmt`. Defaults to
101    /// [`DEFAULT_OUTPUT_PIXFMT`] (`yuv420p`). Set to `None` to let ffmpeg (or a
102    /// `-vf`/`-pix_fmt` in the other arg lists) decide, e.g. for hardware
103    /// encoders whose filter chain already fixes the format.
104    pub pixfmt: Option<String>,
105    /// Maximum number of B-frames passed to ffmpeg as `-bf`. `None`, the
106    /// default, lets the encoder (or a `-bf` in the other arg lists) decide.
107    pub max_bframes: Option<u32>,
108}
109
110impl Default for FfmpegCodecArgs {
111    fn default() -> Self {
112        Self {
113            device_args: None,
114            pre_codec_args: None,
115            codec: None,
116            post_codec_args: None,
117            pixfmt: Some(DEFAULT_OUTPUT_PIXFMT.to_string()),
118            max_bframes: None,
119        }
120    }
121}
122
123fn prefix() -> Vec<String> {
124    zq(&["-nostats", "-hide_banner", "-nostdin", "-y"])
125}
126
127fn zq(x: &[&str]) -> Vec<String> {
128    x.iter().map(|x| (*x).into()).collect()
129}
130
131fn zq2(opt_x: Option<&Vec<(String, String)>>) -> Vec<String> {
132    if let Some(x) = opt_x {
133        x.iter()
134            .flat_map(|(x1, x2)| [x1.clone(), x2.clone()])
135            .collect()
136    } else {
137        vec![]
138    }
139}
140
141impl FfmpegCodecArgs {
142    /// Build the full ffmpeg argument list.
143    ///
144    /// `input_args` are inserted immediately before `-i -` and describe the raw
145    /// video arriving on stdin (format, pixel format, size, frame rate, color
146    /// range). We also unconditionally force full-range (`pc`) output, appended
147    /// after `post_codec_args`, so the full 0-255 intensity range is always
148    /// preserved rather than the limited "tv" range: unlike `-pix_fmt`/`-bf`
149    /// above, this is not meant to be overridable, so codec presets (see
150    /// `from_str` below) must not also set `-color_range` in their own args —
151    /// it would just be redundant.
152    fn to_args(&self, input_args: &[String]) -> Vec<String> {
153        const VIDEO_CODEC: &str = "-c:v";
154        let output_color_range = zq(&["-color_range", "pc"]);
155        let input: Vec<String> = input_args.to_vec();
156        let stdin_input = zq(&["-i", "-"]);
157        // Emit `-pix_fmt <fmt>` and `-bf <n>` for the output before
158        // `post_codec_args` so an explicit `-pix_fmt`/`-bf` in `post_codec_args`
159        // still takes precedence.
160        let output_pixfmt = match &self.pixfmt {
161            Some(pixfmt) => zq(&["-pix_fmt", pixfmt]),
162            None => vec![],
163        };
164        let output_bframes = match &self.max_bframes {
165            Some(max_bframes) => vec!["-bf".to_string(), max_bframes.to_string()],
166            None => vec![],
167        };
168        if let Some(codec) = &self.codec {
169            vec![
170                prefix(),
171                zq2(self.device_args.as_ref()),
172                input,
173                stdin_input,
174                zq2(self.pre_codec_args.as_ref()),
175                zq(&[VIDEO_CODEC, codec]),
176                output_pixfmt,
177                output_bframes,
178                zq2(self.post_codec_args.as_ref()),
179                output_color_range,
180            ]
181        } else {
182            assert_eq!(self.device_args, None);
183            assert_eq!(self.pre_codec_args, None);
184            assert_eq!(self.post_codec_args, None);
185            vec![
186                prefix(),
187                input,
188                stdin_input,
189                output_pixfmt,
190                output_bframes,
191                output_color_range,
192            ]
193        }
194        .into_iter()
195        .flatten()
196        .collect()
197    }
198
199    fn from_str(s: &str) -> Option<Self> {
200        match s {
201            // Keep these in sync with the list in strand-cam-remote-control.
202            "vaapi" => Some(Self {
203                device_args: Some(vec![("-vaapi_device".into(), "/dev/dri/renderD128".into())]),
204                pre_codec_args: Some(vec![("-vf".into(), "format=nv12,hwupload".into())]),
205                codec: Some("h264_vaapi".to_string()),
206                // `to_args` already appends `-color_range pc` unconditionally,
207                // so no need to set it here too.
208                // The `format=nv12,hwupload` filter chain already fixes the
209                // format and the encoder works on hardware surfaces; forcing an
210                // output `-pix_fmt` here would conflict.
211                pixfmt: None,
212                ..Default::default()
213            }),
214            "videotoolbox" => Some(Self {
215                codec: Some("h264_videotoolbox".into()),
216                ..Default::default()
217            }),
218            _ => None,
219        }
220    }
221}
222
223pub fn ffmpeg_version() -> Result<String> {
224    let args = ["-hide_banner", "-nostdin", "-version"];
225    let ffmpeg_child = Command::new(FFMPEG)
226        .args(args)
227        .stdin(Stdio::piped())
228        .stdout(Stdio::piped())
229        .stderr(Stdio::piped())
230        .spawn()?;
231    let out = ffmpeg_child.wait_with_output()?;
232    let lines = String::from_utf8(out.stdout)?;
233
234    let mut ffmpeg_stderr_iter = lines.split_ascii_whitespace();
235    assert_eq!(ffmpeg_stderr_iter.next(), Some("ffmpeg"));
236    assert_eq!(ffmpeg_stderr_iter.next(), Some("version"));
237
238    if let Some(version_str) = ffmpeg_stderr_iter.next() {
239        Ok(version_str.into())
240    } else {
241        Err(Error::UnexpectedFfmpegOutput(lines))
242    }
243}
244
245pub fn platform_hardware_encoder() -> Result<FfmpegCodecArgs> {
246    let args = ["-hide_banner", "-nostdin", "-hwaccels"];
247    let ffmpeg_child = Command::new(FFMPEG)
248        .args(args)
249        .stdin(Stdio::piped())
250        .stdout(Stdio::piped())
251        .stderr(Stdio::piped())
252        .spawn()?;
253    let out = ffmpeg_child.wait_with_output()?;
254    let lines = String::from_utf8(out.stdout)?;
255    let mut lines: VecDeque<_> = lines.split("\n").collect();
256    let line0 = lines.pop_front().unwrap();
257    if line0 != "Hardware acceleration methods:" {
258        return Err(Error::UnexpectedFfmpegOutput(line0.into()));
259    }
260    for line in lines.into_iter() {
261        if let Some(opt) = FfmpegCodecArgs::from_str(line) {
262            return Ok(opt);
263        }
264    }
265    Ok(FfmpegCodecArgs {
266        ..Default::default()
267    })
268}
269
270impl FfmpegWriter {
271    pub fn new(
272        fname: &str,
273        ffmpeg_codec_args: FfmpegCodecArgs,
274        rate: Option<(usize, usize)>,
275    ) -> Result<Self> {
276        let (raten, rated) = rate.unwrap_or((25, 1));
277        // ffmpeg is spawned lazily on the first frame, once we know the frame
278        // width, height and pixel format needed for the raw-video input options.
279        Ok(Self {
280            fname: fname.to_string(),
281            ffmpeg_codec_args,
282            raten,
283            rated,
284            count: 0,
285            running: None,
286        })
287    }
288
289    /// Spawn ffmpeg configured to read raw video of this frame's format.
290    fn start(&mut self, frame: &strand_dynamic_frame::DynamicFrame) -> Result<()> {
291        let pixfmt = frame.pixel_format();
292        let ff_pixfmt = ffmpeg_pixel_format(pixfmt)?;
293        let width = frame.width();
294        let height = frame.height();
295
296        // Raw-video input options, placed just before `-i -`. We unconditionally
297        // tag the input as full range (`pc`) so ffmpeg preserves the full 0-255
298        // range; paired with the unconditional `-color_range pc` output tag in
299        // `FfmpegCodecArgs::to_args`, this is not meant to be overridable.
300        let input_args = vec![
301            "-f".to_string(),
302            "rawvideo".to_string(),
303            "-pixel_format".to_string(),
304            ff_pixfmt.to_string(),
305            "-video_size".to_string(),
306            format!("{width}x{height}"),
307            "-framerate".to_string(),
308            format!("{}/{}", self.raten, self.rated),
309            "-color_range".to_string(),
310            "pc".to_string(),
311        ];
312
313        let mut args = self.ffmpeg_codec_args.to_args(&input_args);
314        args.push(self.fname.clone());
315
316        let show_ffmpeg = match std::env::var_os("FFMPEG_WRITER_SHOW") {
317            Some(v) => &v != "0",
318            None => false,
319        };
320        if show_ffmpeg {
321            println!("ffmpeg {}", args.join(" "));
322        }
323
324        let mut cmd0 = Command::new(FFMPEG);
325        let cmd = cmd0.args(args).stdin(Stdio::piped());
326        let cmd = if show_ffmpeg {
327            cmd
328        } else {
329            cmd.stdout(Stdio::piped()).stderr(Stdio::piped())
330        };
331        let mut child = cmd.spawn()?;
332        let stdin = child.stdin.take().expect("failed to get stdin");
333
334        self.running = Some(Running {
335            child,
336            stdin,
337            pixfmt,
338            width,
339            height,
340        });
341        Ok(())
342    }
343
344    /// Write a frame. Return the presentation timestamp (PTS).
345    pub fn write_dynamic_frame(
346        &mut self,
347        frame: &strand_dynamic_frame::DynamicFrame,
348    ) -> Result<std::time::Duration> {
349        if self.running.is_none() {
350            self.start(frame)?;
351        }
352        let running = self.running.as_mut().unwrap();
353        if frame.pixel_format() != running.pixfmt
354            || frame.width() != running.width
355            || frame.height() != running.height
356        {
357            return Err(Error::FormatOrSizeChanged);
358        }
359
360        // Pipe the raw frame data row by row (stripping any stride padding).
361        let stdin = &mut running.stdin;
362        let io_result: std::io::Result<()> = strand_dynamic_frame::match_all_dynamic_fmts!(
363            frame,
364            x,
365            {
366                use machine_vision_formats::iter::HasRowChunksExact;
367                let mut res = Ok(());
368                for row in x.rowchunks_exact() {
369                    if let Err(e) = stdin.write_all(row) {
370                        res = Err(e);
371                        break;
372                    }
373                }
374                res
375            },
376            // Reached only for formats start() did not already reject.
377            Error::UnimplementedPixelFormat(frame.pixel_format())
378        );
379
380        match io_result {
381            Ok(()) => {}
382            Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
383                // ffmpeg apparently died; surface its output as the error.
384                return Err(self.collect_ffmpeg_error());
385            }
386            Err(e) => return Err(e.into()),
387        }
388
389        let num = self.rated * self.count;
390        let dur_sec = num as f64 / self.raten as f64;
391        let pts = std::time::Duration::from_secs_f64(dur_sec);
392        self.count += 1;
393        Ok(pts)
394    }
395
396    /// Wait for the (apparently dead) ffmpeg process and collect its output.
397    fn collect_ffmpeg_error(&mut self) -> Error {
398        let mut running = self.running.take().unwrap();
399        let status = match running.child.wait() {
400            Ok(status) => status,
401            Err(e) => return Error::Io(e),
402        };
403        use std::io::Read;
404        let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
405        if let Some(mut out) = running.child.stdout.take() {
406            let _ = out.read_to_end(&mut stdout);
407        }
408        if let Some(mut err) = running.child.stderr.take() {
409            let _ = err.read_to_end(&mut stderr);
410        }
411        Error::FfmpegError {
412            output: std::process::Output {
413                status,
414                stdout,
415                stderr,
416            },
417        }
418    }
419
420    pub fn close(self) -> Result<()> {
421        // Close ffmpeg's stdin (telling it to finish) by dropping it, then wait.
422        let Some(running) = self.running else {
423            // No frames were ever written, so ffmpeg was never spawned.
424            return Ok(());
425        };
426        let Running { child, stdin, .. } = running;
427        std::mem::drop(stdin);
428        let output = child.wait_with_output()?;
429        if output.status.success() {
430            Ok(())
431        } else {
432            Err(Error::FfmpegError { output })
433        }
434    }
435}
436
437#[cfg(test)]
438mod test {
439    use super::*;
440    use machine_vision_formats::PixFmt;
441    use strand_dynamic_frame::DynamicFrameOwned;
442
443    /// The default codec args force `-pix_fmt yuv420p` on the output (so the
444    /// result is decodable by OpenH264, which only supports 4:2:0), and that
445    /// pixel format appears after the codec but before `post_codec_args` so an
446    /// explicit `-pix_fmt` there can still override it. `None` omits it entirely.
447    #[test]
448    fn to_args_emits_output_pixfmt() {
449        let default_args = FfmpegCodecArgs {
450            codec: Some("libx264".to_string()),
451            ..Default::default()
452        };
453        let args = default_args.to_args(&[]);
454        let pixfmt_at = args.iter().position(|a| a == "-pix_fmt").unwrap();
455        assert_eq!(args[pixfmt_at + 1], "yuv420p");
456        let codec_at = args.iter().position(|a| a == "-c:v").unwrap();
457        assert!(codec_at < pixfmt_at, "pix_fmt must come after the codec");
458
459        // A `None` pixfmt/max_bframes emits no `-pix_fmt`/`-bf`.
460        let no_pixfmt = FfmpegCodecArgs {
461            codec: Some("libx264".to_string()),
462            pixfmt: None,
463            max_bframes: None,
464            ..Default::default()
465        };
466        let args = no_pixfmt.to_args(&[]);
467        assert!(!args.iter().any(|a| a == "-pix_fmt"));
468        assert!(!args.iter().any(|a| a == "-bf"));
469
470        // The struct pixfmt precedes post_codec_args, so an explicit `-pix_fmt`
471        // there is the last one and wins in ffmpeg.
472        let overridden = FfmpegCodecArgs {
473            codec: Some("libx264".to_string()),
474            post_codec_args: Some(vec![("-pix_fmt".into(), "yuv444p".into())]),
475            ..Default::default()
476        };
477        let args = overridden.to_args(&[]);
478        let last_pixfmt = args.iter().rposition(|a| a == "-pix_fmt").unwrap();
479        assert_eq!(args[last_pixfmt + 1], "yuv444p");
480    }
481
482    /// One pixel format's lossless round-trip case.
483    struct Case {
484        pixfmt: PixFmt,
485        /// The ffmpeg raw-video pixel-format name the mapping should produce.
486        /// Hardcoded (not read from the code under test) so it is an independent
487        /// ground truth: the writer encodes using `ffmpeg_pixel_format(pixfmt)`,
488        /// while we decode back using this. If the mapping were wrong, encode
489        /// and decode would disagree and the round trip would not match.
490        ffmpeg_pixfmt: &'static str,
491        /// Bytes per pixel of the packed layout (used to compute row size).
492        bytes_per_pixel: usize,
493        /// A lossless codec that preserves this format's bytes exactly.
494        codec: &'static str,
495        /// Container extension matching `codec`.
496        ext: &'static str,
497    }
498
499    /// Decode the first (only) video frame of `path` back to tightly packed raw
500    /// bytes in `pix_fmt`, via ffmpeg.
501    fn ffmpeg_decode_raw(path: &std::path::Path, pix_fmt: &str) -> Vec<u8> {
502        let output = std::process::Command::new(FFMPEG)
503            .args(["-nostdin", "-loglevel", "error", "-i"])
504            .arg(path)
505            .args(["-f", "rawvideo", "-pix_fmt", pix_fmt, "-"])
506            .output()
507            .expect("running ffmpeg to decode the recording");
508        assert!(
509            output.status.success(),
510            "ffmpeg decode failed: {}",
511            String::from_utf8_lossy(&output.stderr)
512        );
513        output.stdout
514    }
515
516    fn assert_roundtrips_exactly(case: &Case) {
517        // Direct check of the mapping under test against the ground truth.
518        assert_eq!(
519            ffmpeg_pixel_format(case.pixfmt).unwrap(),
520            case.ffmpeg_pixfmt,
521            "unexpected ffmpeg pixel-format mapping for {:?}",
522            case.pixfmt
523        );
524        let ffmpeg_pixfmt = case.ffmpeg_pixfmt;
525        let (width, height) = (64u32, 48u32);
526        let valid_stride = width as usize * case.bytes_per_pixel;
527        // Give the frame stride padding so we also exercise the writer stripping
528        // it off before piping (rows must arrive tightly packed).
529        let pad = 16usize;
530        let stride = valid_stride + pad;
531
532        // Deterministic, non-constant content so any misframing or channel-order
533        // mistake in the pixel-format mapping would change the decoded bytes.
534        let mut buf = vec![0xAAu8; height as usize * stride]; // padding sentinel
535        let mut expected = Vec::with_capacity(height as usize * valid_stride);
536        for row in 0..height as usize {
537            for i in 0..valid_stride {
538                let v = ((row * valid_stride + i) * 31 + 7) as u8;
539                buf[row * stride + i] = v;
540                expected.push(v);
541            }
542        }
543
544        let frame = DynamicFrameOwned::from_buf(width, height, stride, buf, case.pixfmt).unwrap();
545
546        let tmp = tempfile::tempdir().unwrap();
547        let out_path = tmp.path().join(format!("roundtrip.{}", case.ext));
548        {
549            let codec_args = FfmpegCodecArgs {
550                codec: Some(case.codec.to_string()),
551                // This test asserts a byte-exact lossless roundtrip in the
552                // frame's native pixel format, so we must NOT force an output
553                // `-pix_fmt` (which would convert the pixels) nor `-bf` (the
554                // rawvideo/ffv1 encoders reject it).
555                pixfmt: None,
556                max_bframes: None,
557                ..Default::default()
558            };
559            let mut wtr = FfmpegWriter::new(out_path.to_str().unwrap(), codec_args, None).unwrap();
560            wtr.write_dynamic_frame(&frame.borrow()).unwrap();
561            wtr.close().unwrap();
562        }
563
564        let got = ffmpeg_decode_raw(&out_path, ffmpeg_pixfmt);
565        assert_eq!(
566            got.len(),
567            expected.len(),
568            "{:?} ({ffmpeg_pixfmt}): decoded byte count differs",
569            case.pixfmt
570        );
571        assert!(
572            got == expected,
573            "{:?} ({ffmpeg_pixfmt}): pixel data did not round-trip exactly through ffmpeg",
574            case.pixfmt
575        );
576    }
577
578    /// Frame data piped raw to ffmpeg (see the crate docs / `ffmpeg_pixel_format`)
579    /// must survive a round trip byte-for-byte. Mono8/RGB8 go through the lossless
580    /// FFV1 codec, which also interprets the colorspace and so catches
581    /// channel-order mistakes (e.g. RGB vs BGR). YUV422 and Bayer use the verbatim
582    /// `rawvideo` codec: FFV1 has no packed 4:2:2 format, so encoding uyvy422 with
583    /// it forces a chroma repack through swscale that is not bit-exact across
584    /// ffmpeg versions, and Bayer has no non-debayering codec at all. For those
585    /// two, the pixel-format mapping is instead guarded by the direct
586    /// `ffmpeg_pixel_format` assertion above; that the real (H.264) encoder accepts
587    /// each format is covered by the sim smoke test.
588    #[test]
589    fn frame_data_roundtrips_exactly_via_ffmpeg() {
590        let cases = [
591            Case {
592                pixfmt: PixFmt::Mono8,
593                ffmpeg_pixfmt: "gray",
594                bytes_per_pixel: 1,
595                codec: "ffv1",
596                ext: "mkv",
597            },
598            Case {
599                pixfmt: PixFmt::RGB8,
600                ffmpeg_pixfmt: "rgb24",
601                bytes_per_pixel: 3,
602                codec: "ffv1",
603                ext: "mkv",
604            },
605            Case {
606                pixfmt: PixFmt::YUV422,
607                ffmpeg_pixfmt: "uyvy422",
608                bytes_per_pixel: 2,
609                codec: "rawvideo",
610                ext: "nut",
611            },
612            Case {
613                pixfmt: PixFmt::BayerRG8,
614                ffmpeg_pixfmt: "bayer_rggb8",
615                bytes_per_pixel: 1,
616                codec: "rawvideo",
617                ext: "nut",
618            },
619        ];
620        for case in &cases {
621            assert_roundtrips_exactly(case);
622        }
623    }
624}