1use crate::app::BrepApp;
12use serde::Serialize;
13
14#[derive(Debug, Clone, PartialEq)]
16pub struct ExportSpec {
17 pub study: Option<String>,
19 pub format: String,
21 pub width: u32,
22 pub height: u32,
23 pub fps: Option<u32>,
26 pub path: Option<String>,
29}
30
31#[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 pub head: String,
46}
47
48impl ExportReport {
49 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
58const SIZE_RANGE: std::ops::RangeInclusive<u32> = 16..=4096;
60
61pub 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 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 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
128pub(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 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 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
218pub(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