1use std::collections::HashMap;
2use std::fs::{self, OpenOptions};
3use std::io::{BufRead, BufReader, Write};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result, bail};
9use serde::{Deserialize, Serialize};
10
11use crate::frame::{Attributes, Cell, Frame, from_screen};
12use crate::render;
13use crate::shot::Shot;
14
15const MAX_VIDEO_FPS: u32 = 1000;
16pub const FORMAT_VERSION: u8 = 1;
18
19#[derive(Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22#[serde(tag = "type", rename_all = "lowercase")]
23pub enum Entry {
24 Header {
25 version: u8,
26 cols: u16,
27 rows: u16,
28 cell_width: u16,
29 cell_height: u16,
30 },
31 Output {
32 at_ms: u64,
33 bytes: Vec<u8>,
34 },
35 Input {
36 at_ms: u64,
37 origin: InputOrigin,
38 bytes: Vec<u8>,
39 },
40 Resize {
41 at_ms: u64,
42 cols: u16,
43 rows: u16,
44 cell_width: u16,
45 cell_height: u16,
46 },
47 Marker {
48 at_ms: u64,
49 name: String,
50 },
51}
52
53#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum InputOrigin {
57 Client,
58 Host,
59}
60
61pub struct Writer {
62 file: fs::File,
63 started: Instant,
64}
65
66impl Writer {
67 pub fn new(
68 path: &Path,
69 started: Instant,
70 cols: u16,
71 rows: u16,
72 cell_width: u16,
73 cell_height: u16,
74 ) -> Result<Self> {
75 crate::shot::validate_geometry(rows, cols)?;
76 if let Some(parent) = path
77 .parent()
78 .filter(|parent| !parent.as_os_str().is_empty())
79 {
80 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
81 }
82 let mut open = OpenOptions::new();
83 open.create(true).write(true).truncate(true);
84 #[cfg(unix)]
85 {
86 use std::os::unix::fs::OpenOptionsExt;
87 open.mode(0o600);
88 }
89 let mut file = open
90 .open(path)
91 .with_context(|| format!("create {}", path.display()))?;
92 #[cfg(unix)]
93 {
94 use std::os::unix::fs::PermissionsExt;
95 fs::set_permissions(path, fs::Permissions::from_mode(0o600))
96 .with_context(|| format!("secure {}", path.display()))?;
97 }
98 serde_json::to_writer(
99 &mut file,
100 &Entry::Header {
101 version: FORMAT_VERSION,
102 cols,
103 rows,
104 cell_width,
105 cell_height,
106 },
107 )
108 .context("write recording header")?;
109 file.write_all(b"\n").context("write recording newline")?;
110 file.flush().context("flush recording header")?;
111 Ok(Self { file, started })
112 }
113
114 pub fn output(&mut self, at_ms: u64, bytes: &[u8]) -> Result<()> {
115 self.write(Entry::Output {
116 at_ms,
117 bytes: bytes.to_vec(),
118 })
119 }
120
121 pub fn input(&mut self, origin: InputOrigin, bytes: &[u8]) -> Result<()> {
122 self.write(Entry::Input {
123 at_ms: self.started.elapsed().as_millis() as u64,
124 origin,
125 bytes: bytes.to_vec(),
126 })
127 }
128
129 pub fn resize(
130 &mut self,
131 cols: u16,
132 rows: u16,
133 cell_width: u16,
134 cell_height: u16,
135 ) -> Result<()> {
136 crate::shot::validate_geometry(rows, cols)?;
137 self.write(Entry::Resize {
138 at_ms: self.started.elapsed().as_millis() as u64,
139 cols,
140 rows,
141 cell_width,
142 cell_height,
143 })
144 }
145
146 pub fn marker(&mut self, name: &str) -> Result<()> {
147 if name.is_empty() {
148 bail!("marker name must not be empty");
149 }
150 self.write(Entry::Marker {
151 at_ms: self.started.elapsed().as_millis() as u64,
152 name: name.to_owned(),
153 })
154 }
155
156 fn write(&mut self, entry: Entry) -> Result<()> {
157 serde_json::to_writer(&mut self.file, &entry).context("write recording event")?;
158 self.file
159 .write_all(b"\n")
160 .context("write recording newline")?;
161 self.file.flush().context("flush recording event")
162 }
163}
164
165pub struct VideoOptions {
166 pub out: PathBuf,
167 pub cell_width: Option<u16>,
168 pub cell_height: Option<u16>,
169 pub padding: f32,
170 pub font_family: String,
171 pub pixel_ratio: f32,
172 pub hide_cursor: bool,
173 pub footer: bool,
174 pub fps: u32,
175 pub tail: Duration,
176 pub include_startup: bool,
177 pub edit: Option<PathBuf>,
178}
179
180pub fn video(path: &Path, options: &VideoOptions) -> Result<()> {
181 if options.fps == 0 {
182 bail!("--fps must be greater than zero");
183 }
184 if options.fps > MAX_VIDEO_FPS {
185 bail!("--fps must not exceed {MAX_VIDEO_FPS}");
186 }
187 let recording = read(path)?;
188 let states = states(&recording);
189 let states = visible_states(&states, options.include_startup);
190 if states.is_empty() {
191 bail!("recording contains no visible output frames");
192 }
193 let caption_placement = if options.footer {
194 CaptionPlacement::Footer
195 } else {
196 CaptionPlacement::Inline
197 };
198 let states = match &options.edit {
199 Some(path) => edited_states(
200 states,
201 &recording.events,
202 &read_edit(path)?,
203 caption_placement,
204 )?,
205 None => states.to_vec(),
206 };
207 let samples = samples(&states, options);
208 if let Some(parent) = options
209 .out
210 .parent()
211 .filter(|parent| !parent.as_os_str().is_empty())
212 {
213 fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
214 }
215 let temp = std::env::temp_dir().join(format!(
216 "termctrl-video-{}-{}",
217 std::process::id(),
218 SystemTime::now()
219 .duration_since(UNIX_EPOCH)
220 .unwrap_or_default()
221 .as_nanos()
222 ));
223 fs::create_dir_all(&temp).with_context(|| format!("create {}", temp.display()))?;
224 #[cfg(unix)]
225 {
226 use std::os::unix::fs::PermissionsExt;
227 fs::set_permissions(&temp, fs::Permissions::from_mode(0o700))
228 .with_context(|| format!("secure {}", temp.display()))?;
229 }
230 let result = render_video_frames(&temp, &recording, &states, &samples, options);
231 let _ = fs::remove_dir_all(&temp);
232 result
233}
234
235pub struct Recording {
237 pub cols: u16,
238 pub rows: u16,
239 pub cell_width: u16,
240 pub cell_height: u16,
241 pub events: Vec<Entry>,
242}
243
244#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
245pub struct Marker {
246 pub at_ms: u64,
247 pub name: String,
248}
249
250pub fn read(path: &Path) -> Result<Recording> {
252 let file = fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
253 let mut lines = BufReader::new(file).lines();
254 let Some(header) = lines.next() else {
255 bail!("recording is empty");
256 };
257 let Entry::Header {
258 version,
259 cols,
260 rows,
261 cell_width,
262 cell_height,
263 ..
264 } = serde_json::from_str(&header.context("read recording header")?)
265 .context("parse recording header")?
266 else {
267 bail!("recording does not start with a header");
268 };
269 if version != FORMAT_VERSION {
270 bail!("unsupported recording version {version}");
271 }
272 crate::shot::validate_geometry(rows, cols)?;
273 let events = lines
274 .map(|line| {
275 serde_json::from_str(&line.context("read recording event")?)
276 .context("parse recording event")
277 })
278 .collect::<Result<Vec<Entry>>>()?;
279 if events
280 .iter()
281 .any(|entry| matches!(entry, Entry::Header { .. }))
282 {
283 bail!("recording contains a header after the first line");
284 }
285 Ok(Recording {
286 cols,
287 rows,
288 cell_width,
289 cell_height,
290 events,
291 })
292}
293
294pub fn markers(recording: &Recording) -> Vec<Marker> {
295 marker_entries(&recording.events).collect()
296}
297
298pub fn shot_at(path: &Path, at_ms: Option<u64>, marker: Option<&str>) -> Result<Shot> {
299 let recording = read(path)?;
300 let at_ms = match (at_ms, marker) {
301 (Some(_), Some(_)) => bail!("use --at-ms or --at-marker, not both"),
302 (Some(at_ms), None) => at_ms,
303 (None, Some(marker)) => *marker_times(&recording.events)?
304 .get(marker)
305 .with_context(|| format!("recording does not contain marker {marker:?}"))?,
306 (None, None) => u64::MAX,
307 };
308 let replay = replay(&recording, Some(at_ms));
309 Ok(Shot {
310 frame: replay
311 .frames
312 .last()
313 .expect("replay always has an initial frame")
314 .frame
315 .clone(),
316 ansi: replay.ansi,
317 })
318}
319
320#[derive(Clone)]
321struct VideoFrame {
322 at_ms: u64,
323 frame: Frame,
324 footer_caption: Option<String>,
325}
326
327struct Replay {
328 ansi: Vec<u8>,
329 frames: Vec<VideoFrame>,
330}
331
332fn states(recording: &Recording) -> Vec<VideoFrame> {
333 replay(recording, None).frames
334}
335
336fn replay(recording: &Recording, cutoff: Option<u64>) -> Replay {
337 let mut parser = crate::shot::terminal(recording.rows, recording.cols);
338 let mut ansi = Vec::new();
339 let mut frames: Vec<VideoFrame> = Vec::new();
340 frames.push(VideoFrame {
341 at_ms: 0,
342 frame: from_screen(parser.screen()),
343 footer_caption: None,
344 });
345 for event in &recording.events {
346 let at_ms = match event {
347 Entry::Output { at_ms, bytes } => {
348 if cutoff.is_some_and(|cutoff| *at_ms > cutoff) {
349 continue;
350 }
351 ansi.extend_from_slice(bytes);
352 parser.process(bytes);
353 *at_ms
354 }
355 Entry::Resize {
356 at_ms, cols, rows, ..
357 } => {
358 if cutoff.is_some_and(|cutoff| *at_ms > cutoff) {
359 continue;
360 }
361 parser = crate::shot::terminal(*rows, *cols);
362 parser.process(&ansi);
363 *at_ms
364 }
365 Entry::Input { .. } | Entry::Marker { .. } | Entry::Header { .. } => continue,
366 };
367 let frame = from_screen(parser.screen());
368 if frames
369 .last()
370 .is_some_and(|previous| previous.frame == frame)
371 {
372 continue;
373 }
374 frames.push(VideoFrame {
375 at_ms,
376 frame,
377 footer_caption: None,
378 });
379 }
380 Replay { ansi, frames }
381}
382
383fn visible_states(states: &[VideoFrame], include_startup: bool) -> &[VideoFrame] {
384 if include_startup {
385 return states;
386 }
387 let visible = states
388 .iter()
389 .position(|frame| has_non_whitespace_text(&frame.frame))
390 .or_else(|| {
391 states
392 .iter()
393 .position(|frame| frame.frame.has_visible_content())
394 })
395 .unwrap_or(states.len());
396 &states[visible..]
397}
398
399fn has_non_whitespace_text(frame: &Frame) -> bool {
400 frame.cells.iter().any(|cell| !cell.text.trim().is_empty())
401}
402
403#[derive(Deserialize)]
404#[serde(deny_unknown_fields)]
405struct VideoEdit {
406 clips: Vec<VideoEditClip>,
407}
408
409#[derive(Deserialize)]
410#[serde(deny_unknown_fields)]
411struct VideoEditClip {
412 from: String,
413 to: String,
414 caption: Option<String>,
415 speed: Option<f64>,
416 hold_ms: Option<u64>,
417}
418
419#[derive(Clone, Copy, PartialEq, Eq)]
420enum CaptionPlacement {
421 Inline,
422 Footer,
423}
424
425fn read_edit(path: &Path) -> Result<VideoEdit> {
426 let edit = serde_json::from_slice(
427 &fs::read(path).with_context(|| format!("read {}", path.display()))?,
428 )
429 .with_context(|| format!("parse {}", path.display()))?;
430 validate_edit(&edit)?;
431 Ok(edit)
432}
433
434fn validate_edit(edit: &VideoEdit) -> Result<()> {
435 if edit.clips.is_empty() {
436 bail!("video edit must contain at least one clip");
437 }
438 for clip in &edit.clips {
439 if clip.from.is_empty() || clip.to.is_empty() {
440 bail!("video edit clip markers must not be empty");
441 }
442 if clip
443 .caption
444 .as_ref()
445 .is_some_and(|caption| caption.chars().count() > 1000)
446 {
447 bail!("video edit clip caption must not exceed 1000 characters");
448 }
449 }
450 Ok(())
451}
452
453fn edited_states(
454 states: &[VideoFrame],
455 entries: &[Entry],
456 edit: &VideoEdit,
457 caption_placement: CaptionPlacement,
458) -> Result<Vec<VideoFrame>> {
459 validate_edit(edit)?;
460 let markers = marker_times(entries)?;
461 let mut output = Vec::new();
462 let mut offset = 0_u64;
463 for clip in &edit.clips {
464 let from = *markers
465 .get(&clip.from)
466 .with_context(|| format!("video edit references missing marker {:?}", clip.from))?;
467 let to = *markers
468 .get(&clip.to)
469 .with_context(|| format!("video edit references missing marker {:?}", clip.to))?;
470 if from > to {
471 bail!("video edit clip {:?} ends before it starts", clip.from);
472 }
473 let speed = clip.speed.unwrap_or(1.0);
474 if !speed.is_finite() || speed <= 0.0 {
475 bail!(
476 "video edit clip {:?} speed must be greater than zero",
477 clip.from
478 );
479 }
480 let clip_start = offset;
481 let first = states
482 .iter()
483 .rfind(|state| state.at_ms <= from)
484 .or_else(|| states.first())
485 .context("video edit has no visible screen state")?;
486 output.push(VideoFrame {
487 at_ms: offset,
488 frame: frame_with_caption(&first.frame, clip.caption.as_deref(), caption_placement),
489 footer_caption: footer_caption(clip.caption.as_deref(), caption_placement),
490 });
491 output.extend(
492 states
493 .iter()
494 .filter(|state| state.at_ms > from && state.at_ms <= to)
495 .map(|state| VideoFrame {
496 at_ms: scale_clip_time(clip_start, from, state.at_ms, speed),
497 frame: frame_with_caption(
498 &state.frame,
499 clip.caption.as_deref(),
500 caption_placement,
501 ),
502 footer_caption: footer_caption(clip.caption.as_deref(), caption_placement),
503 }),
504 );
505 let hold_ms = clip.hold_ms.unwrap_or(0);
506 offset = scale_clip_time(clip_start, from, to, speed).saturating_add(hold_ms);
507 if hold_ms > 0
508 && let Some(last) = output.last()
509 {
510 output.push(VideoFrame {
511 at_ms: offset,
512 frame: last.frame.clone(),
513 footer_caption: last.footer_caption.clone(),
514 });
515 }
516 }
517 Ok(output)
518}
519
520fn frame_with_caption(frame: &Frame, caption: Option<&str>, placement: CaptionPlacement) -> Frame {
521 match placement {
522 CaptionPlacement::Inline => annotate(frame.clone(), caption),
523 CaptionPlacement::Footer => frame.clone(),
524 }
525}
526
527fn footer_caption(caption: Option<&str>, placement: CaptionPlacement) -> Option<String> {
528 (placement == CaptionPlacement::Footer)
529 .then(|| caption.map(str::to_owned))
530 .flatten()
531}
532
533fn scale_clip_time(clip_start: u64, from: u64, at_ms: u64, speed: f64) -> u64 {
534 clip_start + ((at_ms.saturating_sub(from) as f64) / speed) as u64
535}
536
537fn marker_times(entries: &[Entry]) -> Result<HashMap<String, u64>> {
538 let mut markers = HashMap::new();
539 for marker in marker_entries(entries) {
540 if markers.insert(marker.name.clone(), marker.at_ms).is_some() {
541 bail!("recording contains duplicate marker {:?}", marker.name);
542 }
543 }
544 Ok(markers)
545}
546
547fn marker_entries(entries: &[Entry]) -> impl Iterator<Item = Marker> + '_ {
548 entries.iter().filter_map(|entry| match entry {
549 Entry::Marker { at_ms, name } => Some(Marker {
550 at_ms: *at_ms,
551 name: name.clone(),
552 }),
553 _ => None,
554 })
555}
556
557fn annotate(mut frame: Frame, caption: Option<&str>) -> Frame {
558 let Some(caption) = caption else {
559 return frame;
560 };
561 let text: String = ['>', ' ']
562 .into_iter()
563 .chain(caption.chars())
564 .take(usize::from(frame.cols.saturating_sub(2)))
565 .collect();
566 if text.is_empty() {
567 return frame;
568 }
569 let y = frame.rows;
570 frame.rows = frame.rows.saturating_add(2);
571 push_text_cell(
572 &mut frame,
573 1,
574 y,
575 text,
576 u16::MAX,
577 Attributes {
578 bold: true,
579 ..Attributes::default()
580 },
581 );
582 frame
583}
584
585fn samples(states: &[VideoFrame], options: &VideoOptions) -> Vec<usize> {
586 if states.is_empty() {
587 return Vec::new();
588 }
589 let mut timeline = Vec::with_capacity(states.len());
590 let mut at_ms = 0_u64;
591 for index in 0..states.len() {
592 timeline.push(at_ms);
593 if let Some(next) = states.get(index + 1) {
594 at_ms = at_ms.saturating_add(next.at_ms.saturating_sub(states[index].at_ms));
595 }
596 }
597 let end_ms = at_ms.saturating_add(options.tail.as_millis() as u64);
598 let mut output = Vec::new();
599 let mut state = 0;
600 let mut sample = 0_u64;
601 loop {
602 let sample_ms = u128::from(sample) * 1000 / u128::from(options.fps);
603 if sample_ms > u128::from(end_ms) {
604 break;
605 }
606 let sample_ms = sample_ms as u64;
607 while state + 1 < timeline.len() && timeline[state + 1] <= sample_ms {
608 state += 1;
609 }
610 output.push(state);
611 sample += 1;
612 }
613 if output.last() != Some(&(states.len() - 1)) {
614 output.push(states.len() - 1);
615 }
616 output
617}
618
619fn render_video_frames(
620 temp: &Path,
621 recording: &Recording,
622 states: &[VideoFrame],
623 samples: &[usize],
624 options: &VideoOptions,
625) -> Result<()> {
626 eprintln!("Rendering {} sampled frames...", samples.len());
627 let cols = states
628 .iter()
629 .map(|state| state.frame.cols)
630 .max()
631 .unwrap_or(recording.cols);
632 let rows = states
633 .iter()
634 .map(|state| state.frame.rows)
635 .max()
636 .unwrap_or(recording.rows);
637 let base_keys = states
638 .iter()
639 .map(|state| render_key(&state.frame, cols, rows, options.hide_cursor))
640 .collect::<Vec<_>>();
641 let mut rendered = HashMap::<Frame, PathBuf>::new();
642 let renderer = render::PngRenderer::new();
643 let render_options = render::Options {
644 cell_width: f32::from(options.cell_width.unwrap_or(recording.cell_width)),
645 cell_height: f32::from(options.cell_height.unwrap_or(recording.cell_height)),
646 font_size: f32::from(options.cell_height.unwrap_or(recording.cell_height)) * 0.78,
647 padding: options.padding,
648 font_family: options.font_family.clone(),
649 show_cursor: !options.hide_cursor,
650 };
651 for (index, state) in samples.iter().enumerate() {
652 let path = temp.join(format!("frame-{index:06}.png"));
653 if options.footer {
654 let key = with_footer(
655 base_keys[*state].clone(),
656 states[*state].footer_caption.as_deref(),
657 (u128::from(index as u64) * 1000 / u128::from(options.fps)) as u64,
658 );
659 render_or_link(
660 &renderer,
661 &mut rendered,
662 &key,
663 &path,
664 &render_options,
665 options.pixel_ratio,
666 )?;
667 } else {
668 render_or_link(
669 &renderer,
670 &mut rendered,
671 &base_keys[*state],
672 &path,
673 &render_options,
674 options.pixel_ratio,
675 )?;
676 }
677 }
678 eprintln!("Rendered {} unique screens.", rendered.len());
679 eprintln!("Encoding {}...", options.out.display());
680 let status = Command::new("ffmpeg")
681 .args(["-y", "-loglevel", "error", "-framerate"])
682 .arg(options.fps.to_string())
683 .arg("-i")
684 .arg(temp.join("frame-%06d.png"))
685 .args(["-vf", "format=yuv420p", "-movflags", "+faststart"])
686 .arg(&options.out)
687 .status()
688 .context("run ffmpeg; install ffmpeg to export recorded sessions as video")?;
689 if !status.success() {
690 bail!("ffmpeg failed while exporting {}", options.out.display());
691 }
692 Ok(())
693}
694
695fn render_or_link(
696 renderer: &render::PngRenderer,
697 rendered: &mut HashMap<Frame, PathBuf>,
698 key: &Frame,
699 path: &Path,
700 options: &render::Options,
701 pixel_ratio: f32,
702) -> Result<()> {
703 if let Some(existing) = rendered.get(key) {
704 fs::hard_link(existing, path).or_else(|_| fs::copy(existing, path).map(|_| ()))?;
705 return Ok(());
706 }
707 renderer.render(&render::svg(key, options), path, pixel_ratio)?;
708 rendered.insert(key.clone(), path.to_path_buf());
709 Ok(())
710}
711
712fn render_key(frame: &Frame, cols: u16, rows: u16, hide_cursor: bool) -> Frame {
713 let mut frame = frame.clone();
714 frame.cols = cols;
715 frame.rows = rows;
716 if hide_cursor {
717 frame.cursor = None;
718 }
719 frame
720}
721
722fn with_footer(mut frame: Frame, caption: Option<&str>, elapsed_ms: u64) -> Frame {
723 const BRAND: &str = "TERMINAL CONTROL";
724 let footer_y = frame.rows.saturating_add(1);
725 frame.rows = frame.rows.saturating_add(2);
726
727 let timecode = format_timecode(elapsed_ms);
728 let brand_width = text_width(BRAND);
729 let time_width = text_width(&timecode);
730 let brand = (brand_width <= frame.cols).then(|| (frame.cols - brand_width, BRAND));
731 let mut reserved_from = brand
732 .map(|(x, _)| x.saturating_sub(1))
733 .unwrap_or(frame.cols);
734 let time = if time_width <= reserved_from {
735 let ideal_x = frame.cols.saturating_sub(time_width) / 2;
736 let max_x = reserved_from - time_width;
737 Some((ideal_x.min(max_x), timecode.as_str()))
738 } else {
739 None
740 };
741 if let Some((x, _)) = time {
742 reserved_from = x.saturating_sub(1);
743 }
744
745 if let Some(caption) = caption {
746 push_footer_cell(
747 &mut frame,
748 1,
749 footer_y,
750 caption,
751 reserved_from.saturating_sub(1),
752 true,
753 false,
754 );
755 }
756 if let Some((x, text)) = time {
757 push_footer_cell(&mut frame, x, footer_y, text, time_width, true, false);
758 }
759 if let Some((x, text)) = brand {
760 push_footer_cell(&mut frame, x, footer_y, text, brand_width, false, true);
761 }
762 frame
763}
764
765fn push_footer_cell(
766 frame: &mut Frame,
767 x: u16,
768 y: u16,
769 text: &str,
770 max_width: u16,
771 faint: bool,
772 bold: bool,
773) {
774 push_text_cell(
775 frame,
776 x,
777 y,
778 text,
779 max_width,
780 Attributes {
781 bold,
782 faint,
783 ..Attributes::default()
784 },
785 );
786}
787
788fn push_text_cell(
789 frame: &mut Frame,
790 x: u16,
791 y: u16,
792 text: impl AsRef<str>,
793 max_width: u16,
794 attributes: Attributes,
795) {
796 if x >= frame.cols || y >= frame.rows || max_width == 0 {
797 return;
798 }
799 let available = (frame.cols - x).min(max_width);
800 let text = truncate(text.as_ref(), available);
801 if text.is_empty() {
802 return;
803 }
804 frame.cells.push(Cell {
805 x,
806 y,
807 width: text_width(&text),
808 text,
809 foreground: frame.foreground,
810 background: frame.background,
811 attributes,
812 });
813}
814
815fn truncate(text: &str, max_width: u16) -> String {
816 text.chars().take(usize::from(max_width)).collect()
817}
818
819fn text_width(text: &str) -> u16 {
820 text.chars().count().min(usize::from(u16::MAX)) as u16
821}
822
823fn format_timecode(elapsed_ms: u64) -> String {
824 let total_seconds = elapsed_ms / 1000;
825 let seconds = total_seconds % 60;
826 let minutes = (total_seconds / 60) % 60;
827 let hours = total_seconds / 3600;
828 if hours > 0 {
829 format!("{hours:02}:{minutes:02}:{seconds:02}")
830 } else {
831 format!("{minutes:02}:{seconds:02}")
832 }
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838
839 fn frame(text: &str) -> Frame {
840 Frame {
841 version: 1,
842 cols: 40,
843 rows: 1,
844 foreground: crate::frame::DEFAULT_FOREGROUND,
845 background: crate::frame::DEFAULT_BACKGROUND,
846 cursor: None,
847 cells: (!text.is_empty())
848 .then(|| crate::frame::Cell {
849 x: 0,
850 y: 0,
851 text: text.to_owned(),
852 width: 1,
853 foreground: crate::frame::DEFAULT_FOREGROUND,
854 background: crate::frame::DEFAULT_BACKGROUND,
855 attributes: crate::frame::Attributes::default(),
856 })
857 .into_iter()
858 .collect(),
859 }
860 }
861
862 fn options() -> VideoOptions {
863 VideoOptions {
864 out: PathBuf::from("video.mp4"),
865 cell_width: None,
866 cell_height: None,
867 padding: 0.0,
868 font_family: String::new(),
869 pixel_ratio: 1.0,
870 hide_cursor: true,
871 footer: false,
872 fps: 20,
873 tail: Duration::ZERO,
874 include_startup: false,
875 edit: None,
876 }
877 }
878
879 fn edit(from: &str, to: &str) -> VideoEdit {
880 VideoEdit {
881 clips: vec![VideoEditClip {
882 from: from.to_owned(),
883 to: to.to_owned(),
884 caption: None,
885 speed: None,
886 hold_ms: None,
887 }],
888 }
889 }
890
891 fn painted_frame() -> Frame {
892 let mut parser = crate::shot::terminal(1, 2);
893 parser.process(b"\x1b[48;2;30;34;42m ");
894 from_screen(parser.screen())
895 }
896
897 #[test]
898 fn realtime_sampling_preserves_recorded_duration() {
899 let initial = frame("a");
900 let final_frame = frame("b");
901
902 let frames = samples(
903 &[
904 VideoFrame {
905 at_ms: 0,
906 frame: initial,
907 footer_caption: None,
908 },
909 VideoFrame {
910 at_ms: 4000,
911 frame: final_frame.clone(),
912 footer_caption: None,
913 },
914 ],
915 &options(),
916 );
917
918 assert_eq!(frames.len(), 81);
919 assert_eq!(frames.last(), Some(&1));
920 }
921
922 #[test]
923 fn edit_plan_stitches_marker_ranges_with_speed_hold_and_caption() {
924 let first = frame("a");
925 let second = frame("b");
926 let states = edited_states(
927 &[
928 VideoFrame {
929 at_ms: 0,
930 frame: first.clone(),
931 footer_caption: None,
932 },
933 VideoFrame {
934 at_ms: 1000,
935 frame: second.clone(),
936 footer_caption: None,
937 },
938 VideoFrame {
939 at_ms: 2000,
940 frame: first.clone(),
941 footer_caption: None,
942 },
943 ],
944 &[
945 Entry::Marker {
946 at_ms: 0,
947 name: "start".to_owned(),
948 },
949 Entry::Marker {
950 at_ms: 2000,
951 name: "done".to_owned(),
952 },
953 ],
954 &VideoEdit {
955 clips: vec![VideoEditClip {
956 from: "start".to_owned(),
957 to: "done".to_owned(),
958 caption: Some("accelerated".to_owned()),
959 speed: Some(2.0),
960 hold_ms: Some(500),
961 }],
962 },
963 CaptionPlacement::Inline,
964 )
965 .unwrap();
966
967 assert_eq!(
968 states.iter().map(|state| state.at_ms).collect::<Vec<_>>(),
969 [0, 500, 1000, 1500]
970 );
971 assert_eq!(states[0].frame.rows, 3);
972 assert!(states[0].frame.text().contains("accelerated"));
973 assert_eq!(states.last().unwrap().frame.text(), states[2].frame.text());
974 }
975
976 #[test]
977 fn edit_plan_can_place_captions_in_footer_metadata() {
978 let states = edited_states(
979 &[VideoFrame {
980 at_ms: 0,
981 frame: frame("a"),
982 footer_caption: None,
983 }],
984 &[
985 Entry::Marker {
986 at_ms: 0,
987 name: "start".to_owned(),
988 },
989 Entry::Marker {
990 at_ms: 0,
991 name: "done".to_owned(),
992 },
993 ],
994 &VideoEdit {
995 clips: vec![VideoEditClip {
996 from: "start".to_owned(),
997 to: "done".to_owned(),
998 caption: Some("footer caption".to_owned()),
999 speed: None,
1000 hold_ms: None,
1001 }],
1002 },
1003 CaptionPlacement::Footer,
1004 )
1005 .unwrap();
1006
1007 assert_eq!(states[0].frame.rows, 1);
1008 assert_eq!(states[0].frame.text(), "a");
1009 assert_eq!(states[0].footer_caption.as_deref(), Some("footer caption"));
1010 }
1011
1012 #[test]
1013 fn footer_adds_caption_timecode_and_branding() {
1014 let frame = with_footer(frame("body"), Some("demo caption"), 65_000);
1015 let text = frame.text();
1016
1017 assert_eq!(frame.rows, 3);
1018 assert!(text.contains("body"));
1019 assert!(text.contains("demo caption"));
1020 assert!(text.contains("01:05"));
1021 assert!(text.contains("TERMINAL CONTROL"));
1022 }
1023
1024 #[test]
1025 fn footer_avoids_overlapping_cells_when_narrow() {
1026 let mut narrow = frame("body");
1027 narrow.cols = 10;
1028 let frame = with_footer(narrow, Some("demo caption"), 65_000);
1029 let mut spans = frame
1030 .cells
1031 .iter()
1032 .filter(|cell| cell.y == 2)
1033 .map(|cell| (cell.x, cell.x + cell.width))
1034 .collect::<Vec<_>>();
1035 spans.sort();
1036
1037 assert!(spans.iter().all(|(_, end)| *end <= frame.cols));
1038 assert!(spans.windows(2).all(|pair| pair[0].1 <= pair[1].0));
1039 }
1040
1041 #[test]
1042 fn edit_plan_rejects_missing_or_duplicate_markers() {
1043 let states = [VideoFrame {
1044 at_ms: 0,
1045 frame: frame("a"),
1046 footer_caption: None,
1047 }];
1048
1049 assert!(
1050 edited_states(
1051 &states,
1052 &[],
1053 &edit("missing", "done"),
1054 CaptionPlacement::Inline
1055 )
1056 .is_err()
1057 );
1058 assert!(
1059 edited_states(
1060 &states,
1061 &[
1062 Entry::Marker {
1063 at_ms: 0,
1064 name: "start".to_owned(),
1065 },
1066 Entry::Marker {
1067 at_ms: 1,
1068 name: "start".to_owned(),
1069 },
1070 ],
1071 &edit("start", "start"),
1072 CaptionPlacement::Inline,
1073 )
1074 .is_err()
1075 );
1076 }
1077
1078 #[test]
1079 fn preserves_input_origin_and_binary_output() {
1080 let temp =
1081 std::env::temp_dir().join(format!("termctrl-recording-test-{}", std::process::id()));
1082 let mut writer = Writer::new(&temp, Instant::now(), 2, 1, 9, 18).unwrap();
1083 writer.output(1, &[0, 255, b'A']).unwrap();
1084 writer.input(InputOrigin::Host, b"reply").unwrap();
1085 writer.marker("checkpoint").unwrap();
1086 drop(writer);
1087
1088 let recording = read(&temp).unwrap();
1089 let _ = fs::remove_file(temp);
1090 assert!(matches!(
1091 &recording.events[0],
1092 Entry::Output { at_ms: 1, bytes } if bytes == &[0, 255, b'A']
1093 ));
1094 assert!(matches!(
1095 &recording.events[1],
1096 Entry::Input { origin: InputOrigin::Host, bytes, .. } if bytes == b"reply"
1097 ));
1098 assert!(matches!(
1099 &recording.events[2],
1100 Entry::Marker { name, .. } if name == "checkpoint"
1101 ));
1102 }
1103
1104 #[test]
1105 fn replays_resized_recordings_on_a_stable_video_canvas() {
1106 let recording = Recording {
1107 cols: 2,
1108 rows: 1,
1109 cell_width: 9,
1110 cell_height: 18,
1111 events: vec![
1112 Entry::Output {
1113 at_ms: 1,
1114 bytes: b"a".to_vec(),
1115 },
1116 Entry::Resize {
1117 at_ms: 2,
1118 cols: 4,
1119 rows: 2,
1120 cell_width: 9,
1121 cell_height: 18,
1122 },
1123 ],
1124 };
1125
1126 let states = states(&recording);
1127 let cols = states.iter().map(|state| state.frame.cols).max().unwrap();
1128 let rows = states.iter().map(|state| state.frame.rows).max().unwrap();
1129 let frames = states
1130 .iter()
1131 .map(|state| render_key(&state.frame, cols, rows, true))
1132 .collect::<Vec<_>>();
1133
1134 assert!(
1135 frames
1136 .iter()
1137 .all(|frame| (frame.cols, frame.rows) == (4, 2))
1138 );
1139 assert_eq!(frames.last().unwrap().text(), "a");
1140 }
1141
1142 #[test]
1143 fn preserves_background_only_output_when_no_text_is_recorded() {
1144 let painted = painted_frame();
1145 let frames = vec![
1146 VideoFrame {
1147 at_ms: 0,
1148 frame: frame(""),
1149 footer_caption: None,
1150 },
1151 VideoFrame {
1152 at_ms: 1,
1153 frame: painted.clone(),
1154 footer_caption: None,
1155 },
1156 ];
1157
1158 assert_eq!(visible_states(&frames, false)[0].frame, painted);
1159 }
1160
1161 #[test]
1162 fn keeps_final_change_between_sampling_ticks() {
1163 let initial = frame("a");
1164 let final_frame = frame("b");
1165 let frames = samples(
1166 &[
1167 VideoFrame {
1168 at_ms: 0,
1169 frame: initial.clone(),
1170 footer_caption: None,
1171 },
1172 VideoFrame {
1173 at_ms: 1,
1174 frame: final_frame.clone(),
1175 footer_caption: None,
1176 },
1177 ],
1178 &options(),
1179 );
1180
1181 assert_eq!(frames, vec![0, 1]);
1182 }
1183
1184 #[test]
1185 fn samples_fractional_frame_intervals_without_an_early_transition() {
1186 let initial = frame("a");
1187 let final_frame = frame("b");
1188 let mut options = options();
1189 options.fps = 30;
1190
1191 let frames = samples(
1192 &[
1193 VideoFrame {
1194 at_ms: 0,
1195 frame: initial.clone(),
1196 footer_caption: None,
1197 },
1198 VideoFrame {
1199 at_ms: 100,
1200 frame: final_frame.clone(),
1201 footer_caption: None,
1202 },
1203 ],
1204 &options,
1205 );
1206
1207 assert_eq!(frames, vec![0, 0, 0, 1]);
1208 }
1209
1210 #[test]
1211 fn rejects_excessive_video_frame_rates_before_reading_input() {
1212 let mut options = options();
1213 options.fps = MAX_VIDEO_FPS + 1;
1214
1215 assert_eq!(
1216 video(Path::new("not-read.termctrl"), &options)
1217 .unwrap_err()
1218 .to_string(),
1219 "--fps must not exceed 1000"
1220 );
1221 }
1222
1223 #[test]
1224 fn rejects_invalid_geometry_and_repeated_headers() {
1225 let invalid =
1226 std::env::temp_dir().join(format!("termctrl-invalid-recording-{}", std::process::id()));
1227 fs::write(&invalid, "{\"type\":\"header\",\"version\":1,\"cols\":0,\"rows\":1,\"cell_width\":9,\"cell_height\":18}\n").unwrap();
1228 assert!(read(&invalid).is_err());
1229 fs::write(&invalid, "{\"type\":\"header\",\"version\":1,\"cols\":1,\"rows\":1,\"cell_width\":9,\"cell_height\":18}\n{\"type\":\"header\",\"version\":1,\"cols\":1,\"rows\":1,\"cell_width\":9,\"cell_height\":18}\n").unwrap();
1230 assert!(read(&invalid).is_err());
1231 let _ = fs::remove_file(invalid);
1232 }
1233}