Skip to content

Commit d3d7c74

Browse files
committed
feat(recording): add camera-writer-repro diagnostic example
Made-with: Cursor
1 parent 6959c29 commit d3d7c74

1 file changed

Lines changed: 390 additions & 0 deletions

File tree

Lines changed: 390 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,390 @@
1+
#[cfg(not(target_os = "macos"))]
2+
fn main() {
3+
eprintln!("camera-writer-repro is only available on macOS");
4+
}
5+
6+
#[cfg(target_os = "macos")]
7+
fn main() -> anyhow::Result<()> {
8+
use cap_camera::{CameraInfo, CapturedFrame, Format};
9+
use cap_camera_ffmpeg::CapturedFrameExt;
10+
use cap_enc_avfoundation::{MP4Encoder, QueueFrameError};
11+
use cap_media_info::VideoInfo;
12+
use cidre::{arc, cm};
13+
use std::{
14+
cmp::Ordering,
15+
env,
16+
path::PathBuf,
17+
sync::mpsc::sync_channel,
18+
time::{Duration, Instant},
19+
};
20+
21+
#[derive(Clone)]
22+
struct ObservedFrame {
23+
sample_buf: arc::R<cm::SampleBuf>,
24+
timestamp: Duration,
25+
subtype: String,
26+
width: u32,
27+
height: u32,
28+
ffmpeg_video_info: Option<VideoInfo>,
29+
ffmpeg_error: Option<String>,
30+
}
31+
32+
#[derive(Clone)]
33+
struct ProbeTarget {
34+
camera: CameraInfo,
35+
format: Format,
36+
}
37+
38+
#[derive(Clone)]
39+
struct ProbeSummary {
40+
camera_name: String,
41+
width: u32,
42+
height: u32,
43+
fps: f32,
44+
received: usize,
45+
queue_failures: Vec<String>,
46+
ffmpeg_failures: Vec<String>,
47+
elapsed_ms: u128,
48+
}
49+
50+
unsafe impl Send for ObservedFrame {}
51+
unsafe impl Sync for ObservedFrame {}
52+
53+
fn bool_flag(args: &[String], flag: &str) -> bool {
54+
args.iter().any(|arg| arg == flag)
55+
}
56+
57+
fn value_flag(args: &[String], flag: &str) -> Option<String> {
58+
args.windows(2)
59+
.find(|window| window[0] == flag)
60+
.map(|window| window[1].clone())
61+
}
62+
63+
fn select_camera(
64+
cameras: &[CameraInfo],
65+
preferred: Option<&str>,
66+
) -> anyhow::Result<CameraInfo> {
67+
if let Some(camera) = preferred.and_then(|preferred_name| {
68+
cameras
69+
.iter()
70+
.find(|camera| camera.display_name().contains(preferred_name))
71+
}) {
72+
return Ok(camera.clone());
73+
}
74+
75+
if let Some(camera) = cameras
76+
.iter()
77+
.find(|camera| camera.display_name() == "MacBook Pro Camera")
78+
{
79+
return Ok(camera.clone());
80+
}
81+
82+
if let Some(camera) = cameras
83+
.iter()
84+
.find(|camera| !camera.display_name().contains("Desk View"))
85+
{
86+
return Ok(camera.clone());
87+
}
88+
89+
cameras
90+
.first()
91+
.cloned()
92+
.ok_or_else(|| anyhow::anyhow!("No cameras available"))
93+
}
94+
95+
fn sorted_formats(camera: &CameraInfo) -> anyhow::Result<Vec<Format>> {
96+
let mut formats = camera
97+
.formats()
98+
.ok_or_else(|| anyhow::anyhow!("No formats reported for {}", camera.display_name()))?;
99+
100+
formats.sort_by(|a, b| {
101+
let target_aspect_ratio = 16.0 / 9.0;
102+
let aspect_ratio_a = a.width() as f32 / a.height() as f32;
103+
let aspect_ratio_b = b.width() as f32 / b.height() as f32;
104+
let aspect_cmp_a = (aspect_ratio_a - target_aspect_ratio).abs();
105+
let aspect_cmp_b = (aspect_ratio_b - target_aspect_ratio).abs();
106+
let aspect_cmp = aspect_cmp_a.partial_cmp(&aspect_cmp_b);
107+
let resolution_cmp = (a.width() * a.height()).cmp(&(b.width() * b.height()));
108+
let fr_cmp = a.frame_rate().partial_cmp(&b.frame_rate());
109+
110+
aspect_cmp
111+
.unwrap_or(Ordering::Equal)
112+
.then(resolution_cmp.reverse())
113+
.then(fr_cmp.unwrap_or(Ordering::Equal).reverse())
114+
});
115+
116+
Ok(formats)
117+
}
118+
119+
fn choose_default_format(camera: &CameraInfo) -> anyhow::Result<Format> {
120+
let formats = sorted_formats(camera)?;
121+
if let Some(format) = formats.iter().find(|format| {
122+
format.frame_rate() >= 30.0 && format.width() < 2000 && format.height() < 2000
123+
}) {
124+
return Ok(format.clone());
125+
}
126+
127+
formats
128+
.first()
129+
.cloned()
130+
.ok_or_else(|| anyhow::anyhow!("No usable formats for {}", camera.display_name()))
131+
}
132+
133+
fn collect_probe_targets(
134+
all_cameras: bool,
135+
format_limit: usize,
136+
preferred_camera: Option<&str>,
137+
) -> anyhow::Result<Vec<ProbeTarget>> {
138+
let cameras = cap_camera::list_cameras().collect::<Vec<_>>();
139+
let selected_cameras = if all_cameras {
140+
cameras
141+
} else {
142+
vec![select_camera(&cameras, preferred_camera)?]
143+
};
144+
145+
let mut targets = Vec::new();
146+
147+
for camera in selected_cameras {
148+
let formats = sorted_formats(&camera)?;
149+
150+
if format_limit <= 1 {
151+
targets.push(ProbeTarget {
152+
camera: camera.clone(),
153+
format: choose_default_format(&camera)?,
154+
});
155+
continue;
156+
}
157+
158+
for format in formats.into_iter().take(format_limit) {
159+
targets.push(ProbeTarget {
160+
camera: camera.clone(),
161+
format,
162+
});
163+
}
164+
}
165+
166+
Ok(targets)
167+
}
168+
169+
fn observe_frame(frame: &CapturedFrame, fps: u32) -> ObservedFrame {
170+
let sample_buf = frame.native().sample_buf().clone();
171+
let (subtype, width, height) = match sample_buf.image_buf() {
172+
Some(image_buf) => {
173+
let width = image_buf.width() as u32;
174+
let height = image_buf.height() as u32;
175+
let subtype = sample_buf
176+
.format_desc()
177+
.map(|desc| {
178+
let mut bytes = desc.media_sub_type().to_be_bytes();
179+
cidre::four_cc_to_str(&mut bytes).to_string()
180+
})
181+
.unwrap_or_else(|| "unknown".to_string());
182+
(subtype, width, height)
183+
}
184+
None => ("no-image-buf".to_string(), 0, 0),
185+
};
186+
187+
let (ffmpeg_video_info, ffmpeg_error) = match frame.as_ffmpeg() {
188+
Ok(ff_frame) => (
189+
Some(VideoInfo::from_raw_ffmpeg(
190+
ff_frame.format(),
191+
ff_frame.width(),
192+
ff_frame.height(),
193+
fps,
194+
)),
195+
None,
196+
),
197+
Err(error) => (None, Some(error.to_string())),
198+
};
199+
200+
ObservedFrame {
201+
sample_buf,
202+
timestamp: frame.timestamp,
203+
subtype,
204+
width,
205+
height,
206+
ffmpeg_video_info,
207+
ffmpeg_error,
208+
}
209+
}
210+
211+
fn run_probe(
212+
target: ProbeTarget,
213+
frame_limit: usize,
214+
timeout_secs: u64,
215+
) -> anyhow::Result<ProbeSummary> {
216+
let fps = target.format.frame_rate().round().max(1.0) as u32;
217+
let output_path = PathBuf::from(format!(
218+
"/tmp/cap-camera-writer-repro-{}-{}x{}-{}.mp4",
219+
target.camera.display_name().replace(' ', "-"),
220+
target.format.width(),
221+
target.format.height(),
222+
fps
223+
));
224+
let _ = std::fs::remove_file(&output_path);
225+
226+
println!(
227+
"Probe camera='{}' format={}x{} @ {:.2}fps output={}",
228+
target.camera.display_name(),
229+
target.format.width(),
230+
target.format.height(),
231+
target.format.frame_rate(),
232+
output_path.display()
233+
);
234+
235+
let (tx, rx) = sync_channel::<ObservedFrame>(frame_limit.max(1) * 2);
236+
let started = Instant::now();
237+
let handle = target
238+
.camera
239+
.start_capturing(target.format.clone(), move |frame| {
240+
let observed = observe_frame(&frame, fps);
241+
let _ = tx.try_send(observed);
242+
})?;
243+
244+
let mut encoder: Option<MP4Encoder> = None;
245+
let mut received = 0usize;
246+
let mut first_timestamp = None;
247+
let mut queue_failures = Vec::new();
248+
let mut ffmpeg_failures = Vec::new();
249+
let deadline = Instant::now() + Duration::from_secs(timeout_secs);
250+
251+
while received < frame_limit && Instant::now() < deadline {
252+
let remaining = deadline.saturating_duration_since(Instant::now());
253+
let Ok(frame) = rx.recv_timeout(remaining.min(Duration::from_millis(500))) else {
254+
continue;
255+
};
256+
257+
let first = *first_timestamp.get_or_insert(frame.timestamp);
258+
let rel_ms = frame.timestamp.saturating_sub(first).as_millis();
259+
let timing = frame.sample_buf.timing_info(0).ok();
260+
let pts_us = timing
261+
.as_ref()
262+
.map(|timing| timing.pts.value * 1_000_000 / timing.pts.scale.max(1) as i64);
263+
let dur_us = timing.as_ref().map(|timing| {
264+
timing.duration.value * 1_000_000 / timing.duration.scale.max(1) as i64
265+
});
266+
267+
println!(
268+
"frame={received} rel_ms={rel_ms} subtype={} size={}x{} pts_us={pts_us:?} dur_us={dur_us:?}",
269+
frame.subtype, frame.width, frame.height
270+
);
271+
272+
if let Some(error) = &frame.ffmpeg_error {
273+
ffmpeg_failures.push(error.clone());
274+
}
275+
276+
if encoder.is_none() {
277+
if let Some(video_info) = frame.ffmpeg_video_info {
278+
encoder = Some(
279+
MP4Encoder::init(output_path.clone(), video_info, None, None)
280+
.map_err(|error| anyhow::anyhow!(error.to_string()))?,
281+
);
282+
} else {
283+
break;
284+
}
285+
}
286+
287+
let result = encoder
288+
.as_mut()
289+
.expect("encoder initialized")
290+
.queue_video_frame(frame.sample_buf.clone(), frame.timestamp);
291+
292+
println!("queue_result={result:?}");
293+
294+
match result {
295+
Ok(()) | Err(QueueFrameError::NotReadyForMore) => {}
296+
Err(QueueFrameError::WriterFailed(err)) => {
297+
queue_failures.push(format!("WriterFailed/{err}"));
298+
break;
299+
}
300+
Err(QueueFrameError::Failed) => {
301+
queue_failures.push("Failed".to_string());
302+
break;
303+
}
304+
Err(err) => {
305+
queue_failures.push(err.to_string());
306+
break;
307+
}
308+
}
309+
310+
received += 1;
311+
}
312+
313+
drop(handle);
314+
315+
if let Some(mut encoder) = encoder {
316+
let finish_ts = first_timestamp
317+
.map(|first| first + Duration::from_secs(2))
318+
.unwrap_or(Duration::from_secs(1));
319+
let finish_result = encoder.finish(Some(finish_ts));
320+
println!("finish_result={finish_result:?}");
321+
}
322+
323+
Ok(ProbeSummary {
324+
camera_name: target.camera.display_name().to_string(),
325+
width: target.format.width(),
326+
height: target.format.height(),
327+
fps: target.format.frame_rate(),
328+
received,
329+
queue_failures,
330+
ffmpeg_failures,
331+
elapsed_ms: started.elapsed().as_millis(),
332+
})
333+
}
334+
335+
let args = env::args().collect::<Vec<_>>();
336+
let preferred_camera =
337+
value_flag(&args, "--camera").or_else(|| env::var("CAP_CAMERA_NAME").ok());
338+
let frame_limit = value_flag(&args, "--frames")
339+
.and_then(|value| value.parse::<usize>().ok())
340+
.unwrap_or(12);
341+
let timeout_secs = value_flag(&args, "--timeout")
342+
.and_then(|value| value.parse::<u64>().ok())
343+
.unwrap_or(8);
344+
let format_limit = value_flag(&args, "--formats")
345+
.and_then(|value| value.parse::<usize>().ok())
346+
.unwrap_or(1);
347+
let all_cameras = bool_flag(&args, "--all-cameras");
348+
let list_only = bool_flag(&args, "--list");
349+
350+
let targets = collect_probe_targets(all_cameras, format_limit, preferred_camera.as_deref())?;
351+
352+
if targets.is_empty() {
353+
return Err(anyhow::anyhow!("No probe targets"));
354+
}
355+
356+
if list_only {
357+
for target in &targets {
358+
println!(
359+
"camera='{}' format={}x{} @ {:.2}fps",
360+
target.camera.display_name(),
361+
target.format.width(),
362+
target.format.height(),
363+
target.format.frame_rate()
364+
);
365+
}
366+
return Ok(());
367+
}
368+
369+
let mut summaries = Vec::new();
370+
371+
for target in targets {
372+
let summary = run_probe(target, frame_limit, timeout_secs)?;
373+
println!(
374+
"summary camera='{}' format={}x{} @ {:.2}fps received={} queue_failures={:?} ffmpeg_failures={:?} elapsed_ms={}",
375+
summary.camera_name,
376+
summary.width,
377+
summary.height,
378+
summary.fps,
379+
summary.received,
380+
summary.queue_failures,
381+
summary.ffmpeg_failures,
382+
summary.elapsed_ms
383+
);
384+
summaries.push(summary);
385+
}
386+
387+
println!("final_summaries={}", summaries.len());
388+
389+
Ok(())
390+
}

0 commit comments

Comments
 (0)