Skip to main content

brep_app/
simulation_export.rs

1//! A motion study as a video file: GIF (the `image` crate's encoder, every
2//! frame its own palette, looping) or MP4 (H.264 through the pure-Rust
3//! `rusty_h264` encoder, muxed by `mp4e`), at a chosen size and fps.
4//!
5//! The study is baked first if need be, then every baked frame is shown the
6//! way playback shows it (poses, clips, camera) and drawn at the export size
7//! through the viewport's own render core under the user's display settings
8//! (`crate::viewport::Viewport::render_rgb`); the playhead goes back where
9//! it stood. Native only: the web app plays studies but writes no video.
10
11use crate::app::BrepApp;
12use serde::Serialize;
13
14/// What to export.
15#[derive(Debug, Clone, PartialEq)]
16pub struct ExportSpec {
17    /// The study id; the active one when absent.
18    pub study: Option<String>,
19    /// `gif` or `mp4`.
20    pub format: String,
21    pub width: u32,
22    pub height: u32,
23    /// Frames per second; the study's when absent (every baked frame is a
24    /// video frame: the fps sets how fast they play).
25    pub fps: Option<u32>,
26    /// Where to write the file; nothing written when absent (the bytes are
27    /// returned either way).
28    pub path: Option<String>,
29}
30
31/// What an export came to.
32#[derive(Debug, Clone, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct ExportReport {
35    pub study: String,
36    pub format: String,
37    pub width: u32,
38    pub height: u32,
39    pub fps: u32,
40    pub frames: usize,
41    pub bytes: usize,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub path: Option<String>,
44    /// The file's first bytes as (lossy) text: `GIF89a`, or an MP4's `ftyp`.
45    pub head: String,
46}
47
48impl ExportReport {
49    /// One line for the pane's notice.
50    pub fn summary(&self) -> String {
51        match &self.path {
52            Some(path) => format!("Wrote {} ({} frames, {}×{} at {} fps, {} bytes)", path, self.frames, self.width, self.height, self.fps, self.bytes),
53            None => format!("Exported {} frames, {}×{} at {} fps, {} bytes", self.frames, self.width, self.height, self.fps, self.bytes),
54        }
55    }
56}
57
58/// The size limits: GIF palettes and the H.264 level both have their price.
59const SIZE_RANGE: std::ops::RangeInclusive<u32> = 16..=4096;
60
61/// Export the study: bake it, draw every frame, encode, write `path` if given.
62pub fn export(app: &mut BrepApp, spec: &ExportSpec) -> Result<(ExportReport, Vec<u8>), String> {
63    let format = spec.format.to_ascii_lowercase();
64    if format != "gif" && format != "mp4" {
65        return Err(format!("no export format '{}': gif or mp4", spec.format));
66    }
67    if !SIZE_RANGE.contains(&spec.width) || !SIZE_RANGE.contains(&spec.height) {
68        return Err(format!("the size must be {}..={} pixels a side, not {}×{}", SIZE_RANGE.start(), SIZE_RANGE.end(), spec.width, spec.height));
69    }
70    if format == "mp4" && (spec.width % 2 == 1 || spec.height % 2 == 1) {
71        return Err(format!("MP4 needs an even width and height, not {}×{}", spec.width, spec.height));
72    }
73    let engine = app.docs.engine_mut();
74    let standing = engine.simulation_playback_json();
75    let before = standing["time"].as_f64().unwrap_or(0.0);
76    let study = engine.simulation_bake_all(spec.study.as_deref())?;
77    let record = engine.simulation()?.and_then(|block| block.study(&study).cloned()).ok_or_else(|| format!("no study '{study}'"))?;
78    let fps = spec.fps.unwrap_or(record.fps).max(1);
79    let frames = record.frame_count();
80    let (width, height) = (spec.width, spec.height);
81    // The camera at the export size: the frame fit (near/far to everything
82    // drawn) reads the camera's own size.
83    let (w0, h0) = (engine.camera.width, engine.camera.height);
84    engine.camera.width = width as f64;
85    engine.camera.height = height as f64;
86    let mut sink = FrameSink::new(&format, width, height, fps)?;
87    let mut failure: Option<String> = None;
88    for index in 0..frames {
89        let time = record.frame_time(index);
90        let engine = app.docs.engine_mut();
91        if let Err(error) = engine.simulation_seek(Some(&study), time) {
92            failure = Some(error);
93            break;
94        }
95        let (camera, _) = engine.fit_camera_and_overlay();
96        match app.viewport.render_rgb(app.docs.engine(), &camera, width, height).and_then(|rgb| sink.push(&rgb)) {
97            Ok(()) => {}
98            Err(error) => {
99                failure = Some(format!("frame {index}: {error}"));
100                break;
101            }
102        }
103    }
104    // The camera's size and the playhead go back whatever happened.
105    let engine = app.docs.engine_mut();
106    engine.camera.width = w0;
107    engine.camera.height = h0;
108    let _ = engine.simulation_seek(Some(&study), before);
109    if let Some(error) = failure {
110        return Err(error);
111    }
112    let bytes = sink.finish()?;
113    let path = match &spec.path {
114        Some(path) if !path.trim().is_empty() => {
115            let path = std::path::PathBuf::from(path.trim());
116            if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
117                std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
118            }
119            std::fs::write(&path, &bytes).map_err(|e| format!("{}: {e}", path.display()))?;
120            Some(path.display().to_string())
121        }
122        _ => None,
123    };
124    let head = String::from_utf8_lossy(&bytes[..bytes.len().min(16)]).into_owned();
125    Ok((ExportReport { study, format, width, height, fps, frames, bytes: bytes.len(), path, head }, bytes))
126}
127
128/// Where the frames go: GIF frames kept until the end (the encoder owns its
129/// output), or H.264 access units encoded as the frames come and muxed at the
130/// end.
131pub(crate) enum FrameSink {
132    Gif { width: u32, height: u32, delay_ms: u32, frames: Vec<image::Frame> },
133    Mp4 { width: u32, height: u32, duration_ms: u32, encoder: rusty_h264_encoder::Encoder, units: Vec<Vec<u8>> },
134}
135
136impl FrameSink {
137    pub(crate) fn new(format: &str, width: u32, height: u32, fps: u32) -> Result<Self, String> {
138        let ms = (1000.0 / fps as f64).round().max(1.0) as u32;
139        Ok(match format {
140            "gif" => FrameSink::Gif { width, height, delay_ms: ms, frames: Vec::new() },
141            "mp4" => {
142                let mut config = rusty_h264_encoder::EncoderConfig::new(width as usize, height as usize);
143                config.framerate = fps as f32;
144                config.gop_size = fps.max(1);
145                // About an eighth of a bit per pixel per frame: crisp CAD edges
146                // at 960×540 / 30 fps come to ~2 Mbps.
147                config.bitrate = ((width as u64 * height as u64 * fps as u64) / 8).clamp(200_000, 40_000_000) as u32;
148                let encoder = rusty_h264_encoder::Encoder::new(config).map_err(|e| format!("h264 encoder: {e:?}"))?;
149                FrameSink::Mp4 { width, height, duration_ms: ms, encoder, units: Vec::new() }
150            }
151            other => return Err(format!("no export format '{other}': gif or mp4")),
152        })
153    }
154
155    /// One frame, packed RGB top row first, `width`×`height`.
156    pub(crate) fn push(&mut self, rgb: &[u8]) -> Result<(), String> {
157        match self {
158            FrameSink::Gif { width, height, delay_ms, frames } => {
159                if rgb.len() != (*width * *height * 3) as usize {
160                    return Err(format!("frame is {} bytes, not {}×{}×3", rgb.len(), width, height));
161                }
162                let mut rgba = Vec::with_capacity(rgb.len() / 3 * 4);
163                for px in rgb.chunks_exact(3) {
164                    rgba.extend_from_slice(&[px[0], px[1], px[2], 255]);
165                }
166                let image = image::RgbaImage::from_raw(*width, *height, rgba).ok_or("gif frame: size")?;
167                let delay = image::Delay::from_numer_denom_ms(((*delay_ms as f64 / 10.0).round().max(1.0) * 10.0) as u32, 1);
168                frames.push(image::Frame::from_parts(image, 0, 0, delay));
169                Ok(())
170            }
171            FrameSink::Mp4 { width, height, encoder, units, .. } => {
172                let frame = rgb_to_i420(rgb, *width, *height)?;
173                units.push(encoder.encode(&frame));
174                Ok(())
175            }
176        }
177    }
178
179    pub(crate) fn finish(self) -> Result<Vec<u8>, String> {
180        match self {
181            FrameSink::Gif { frames, .. } => {
182                if frames.is_empty() {
183                    return Err("nothing to export: the study has no frames".into());
184                }
185                let mut out = Vec::new();
186                {
187                    let mut encoder = image::codecs::gif::GifEncoder::new_with_speed(&mut out, 10);
188                    encoder.set_repeat(image::codecs::gif::Repeat::Infinite).map_err(|e| format!("gif: {e}"))?;
189                    for frame in frames {
190                        encoder.encode_frame(frame).map_err(|e| format!("gif frame: {e}"))?;
191                    }
192                }
193                Ok(out)
194            }
195            FrameSink::Mp4 { width, height, duration_ms, mut encoder, mut units } => {
196                let tail = encoder.flush();
197                if !tail.is_empty() {
198                    units.push(tail);
199                }
200                if units.is_empty() {
201                    return Err("nothing to export: the study has no frames".into());
202                }
203                let mut cursor = std::io::Cursor::new(Vec::new());
204                {
205                    let mut muxer = mp4e::Mp4e::new(&mut cursor);
206                    muxer.set_video_track(width, height, mp4e::Codec::AVC);
207                    for unit in &units {
208                        muxer.encode_video(unit, duration_ms).map_err(|e| format!("mp4: {e:?}"))?;
209                    }
210                    muxer.flush().map_err(|e| format!("mp4: {e:?}"))?;
211                }
212                Ok(cursor.into_inner())
213            }
214        }
215    }
216}
217
218/// Packed RGB (top row first) to a planar 4:2:0 frame (BT.601 limited range),
219/// chroma averaged over each 2×2 block. Even sizes only.
220pub(crate) fn rgb_to_i420(rgb: &[u8], width: u32, height: u32) -> Result<rusty_h264_common::YuvFrame, String> {
221    let (w, h) = (width as usize, height as usize);
222    if rgb.len() != w * h * 3 {
223        return Err(format!("frame is {} bytes, not {w}×{h}×3", rgb.len()));
224    }
225    if w % 2 == 1 || h % 2 == 1 {
226        return Err(format!("MP4 needs an even width and height, not {w}×{h}"));
227    }
228    let mut y = vec![0u8; w * h];
229    let (cw, ch) = (w / 2, h / 2);
230    let mut u = vec![0u8; cw * ch];
231    let mut v = vec![0u8; cw * ch];
232    let luma = |r: f64, g: f64, b: f64| (16.0 + 0.257 * r + 0.504 * g + 0.098 * b).round().clamp(16.0, 235.0) as u8;
233    for row in 0..h {
234        for col in 0..w {
235            let p = (row * w + col) * 3;
236            y[row * w + col] = luma(rgb[p] as f64, rgb[p + 1] as f64, rgb[p + 2] as f64);
237        }
238    }
239    for row in 0..ch {
240        for col in 0..cw {
241            let (mut r, mut g, mut b) = (0.0, 0.0, 0.0);
242            for (dy, dx) in [(0, 0), (0, 1), (1, 0), (1, 1)] {
243                let p = ((row * 2 + dy) * w + col * 2 + dx) * 3;
244                r += rgb[p] as f64;
245                g += rgb[p + 1] as f64;
246                b += rgb[p + 2] as f64;
247            }
248            let (r, g, b) = (r / 4.0, g / 4.0, b / 4.0);
249            u[row * cw + col] = (128.0 - 0.148 * r - 0.291 * g + 0.439 * b).round().clamp(16.0, 240.0) as u8;
250            v[row * cw + col] = (128.0 + 0.439 * r - 0.368 * g - 0.071 * b).round().clamp(16.0, 240.0) as u8;
251        }
252    }
253    Ok(rusty_h264_common::YuvFrame { width: w, height: h, y, u, v })
254}
255