Skip to main content

terminal_control/
recording.rs

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;
16/// Schema version written in the header of every `.termctrl` recording.
17pub const FORMAT_VERSION: u8 = 1;
18
19/// One JSON Lines entry in a `.termctrl` recording timeline.
20#[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/// Source of bytes written to the application while recording a session.
54#[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
235/// Parsed recording metadata and timeline entries.
236pub 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
250/// Read and validate a versioned `.termctrl` JSON Lines recording.
251pub 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}