use crate::config::Target;
use crate::error::SinkError;
use crate::session::{make_session_id, ManifestTarget};
use crate::transcript::{format_srt_timestamp, Transcript};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub mod files {
pub const VIDEO: &str = "recording.mp4";
pub const AUDIO: &str = "audio.wav";
pub const TRANSCRIPT_JSON: &str = "transcript.json";
pub const TRANSCRIPT_SRT: &str = "transcript.srt";
pub const MANIFEST: &str = "recording.json";
pub const PROMPT: &str = "PROMPT.md";
pub const README: &str = "README_FOR_AGENT.md";
}
#[derive(Debug, Clone)]
pub struct Recording {
pub id: String,
pub dir: PathBuf,
pub started_at: DateTime<Utc>,
}
impl Recording {
pub fn new(out_dir: &Path, started_at: DateTime<Utc>, exe_hint: &str) -> Self {
let id = make_session_id(started_at, exe_hint);
let dir = out_dir.join(&id);
Self {
id,
dir,
started_at,
}
}
pub fn video_path(&self) -> PathBuf {
self.dir.join(files::VIDEO)
}
pub fn audio_path(&self) -> PathBuf {
self.dir.join(files::AUDIO)
}
pub fn transcript_json_path(&self) -> PathBuf {
self.dir.join(files::TRANSCRIPT_JSON)
}
pub fn transcript_srt_path(&self) -> PathBuf {
self.dir.join(files::TRANSCRIPT_SRT)
}
pub fn manifest_path(&self) -> PathBuf {
self.dir.join(files::MANIFEST)
}
pub fn prompt_path(&self) -> PathBuf {
self.dir.join(files::PROMPT)
}
pub fn readme_path(&self) -> PathBuf {
self.dir.join(files::README)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct VideoMeta {
pub path: String,
pub container: String,
pub codec: String,
pub fps: f32,
pub width: u32,
pub height: u32,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AudioMeta {
pub path: String,
pub sample_rate: u32,
pub channels: u16,
pub duration_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TranscriptMeta {
pub path: String,
pub srt: String,
pub engine: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub segment_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecordingManifest {
pub session_id: String,
pub tool: String,
pub kind: String,
pub target: ManifestTarget,
pub started_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ended_at: Option<DateTime<Utc>>,
pub video: VideoMeta,
#[serde(skip_serializing_if = "Option::is_none")]
pub audio: Option<AudioMeta>,
pub transcript: TranscriptMeta,
pub artifacts: Vec<String>,
}
impl RecordingManifest {
#[allow(clippy::too_many_arguments)]
pub fn new(
recording: &Recording,
target: &Target,
selected_via: &str,
video: VideoMeta,
audio: Option<AudioMeta>,
transcript: &Transcript,
engine: &str,
model: Option<String>,
ended_at: DateTime<Utc>,
) -> Self {
let transcript_meta = TranscriptMeta {
path: files::TRANSCRIPT_JSON.to_string(),
srt: files::TRANSCRIPT_SRT.to_string(),
engine: engine.to_string(),
model,
segment_count: transcript.segments.len(),
language: transcript.language.clone(),
};
let mut artifacts = vec![files::VIDEO.to_string()];
if audio.is_some() {
artifacts.push(files::AUDIO.to_string());
}
artifacts.extend([
files::TRANSCRIPT_JSON.to_string(),
files::TRANSCRIPT_SRT.to_string(),
files::MANIFEST.to_string(),
files::PROMPT.to_string(),
files::README.to_string(),
]);
Self {
session_id: recording.id.clone(),
tool: format!("framewatch {}", env!("CARGO_PKG_VERSION")),
kind: "recording".to_string(),
target: ManifestTarget::from_target(target, selected_via),
started_at: recording.started_at,
ended_at: Some(ended_at),
video,
audio,
transcript: transcript_meta,
artifacts,
}
}
}
const README_FOR_AGENT: &str = r#"# framewatch recording package
This directory is a single screen recording of one application window with a
synchronized voice narration and its transcript. A human recorded their screen
while speaking instructions; your job is to follow those instructions, using the
video to see exactly what they pointed at.
Files:
1. `PROMPT.md` — START HERE. The task prompt with the full transcript inline.
2. `recording.mp4` — the screen recording (the narration is also muxed in).
3. `audio.wav` — the raw microphone narration (PCM).
4. `transcript.json` — machine-readable transcript: segments with `start_ms`/`end_ms`/`text`.
5. `transcript.srt` — the same transcript as SubRip subtitles (HH:MM:SS,mmm).
6. `recording.json` — manifest: target window, time range, video/audio/transcript meta.
How to consume:
- A text-only model can work entirely from `PROMPT.md` — the transcript is inline.
- A multimodal model SHOULD also look at the video. Each transcript segment's
`start_ms`/`end_ms` is measured from the start of `recording.mp4`, so when the
narration says "click *this*", seek the video to that timestamp to see what
"this" was. Extract a frame at a timestamp with ffmpeg (`-ss` is in seconds):
ffmpeg -ss 12.500 -i recording.mp4 -frames:v 1 frame.png
(start_ms 12500 -> -ss 12.500)
"#;
pub struct PackageWriter {
recording: Recording,
}
impl PackageWriter {
pub fn new(
out_dir: &Path,
started_at: DateTime<Utc>,
exe_hint: &str,
) -> Result<Self, SinkError> {
let recording = Recording::new(out_dir, started_at, exe_hint);
std::fs::create_dir_all(&recording.dir)?;
Ok(Self { recording })
}
pub fn recording(&self) -> &Recording {
&self.recording
}
pub fn write_transcript(&self, transcript: &Transcript) -> Result<(), SinkError> {
let json = serde_json::to_string_pretty(transcript)?;
std::fs::write(self.recording.transcript_json_path(), json)?;
std::fs::write(self.recording.transcript_srt_path(), transcript.to_srt())?;
Ok(())
}
pub fn finalize(
&self,
manifest: &RecordingManifest,
transcript: &Transcript,
) -> Result<(), SinkError> {
let json = serde_json::to_string_pretty(manifest)?;
std::fs::write(self.recording.manifest_path(), json)?;
std::fs::write(self.recording.readme_path(), README_FOR_AGENT)?;
std::fs::write(
self.recording.prompt_path(),
render_prompt(manifest, transcript),
)?;
Ok(())
}
}
pub fn render_prompt(manifest: &RecordingManifest, transcript: &Transcript) -> String {
let dur = human_duration(manifest.video.duration_ms);
let title = manifest
.target
.title
.clone()
.or_else(|| manifest.target.exe.clone())
.unwrap_or_else(|| "the target window".to_string());
let exe = manifest.target.exe.clone().unwrap_or_default();
let window_label = if exe.is_empty() {
format!("\"{title}\"")
} else {
format!("\"{title}\" ({exe})")
};
let mut s = String::new();
s.push_str("# Task from a screen recording\n\n");
s.push_str(&format!(
"A human recorded their screen for {dur} while narrating instructions out loud. \
The recording is in this package. Read the narration below, then carry out what they \
asked. The video lets you see exactly what they were pointing at or referring to.\n\n"
));
let audio_note = if manifest.audio.is_some() {
" The narration audio is muxed into the video and also available standalone as `audio.wav`."
} else {
" (This recording has no audio track.)"
};
s.push_str("## What you have\n");
s.push_str(&format!(
"- `recording.mp4` — the screen capture of {window_label}, {}x{} at {} fps, {dur} long.{audio_note}\n",
manifest.video.width,
manifest.video.height,
fmt_fps(manifest.video.fps),
));
s.push_str(
"- The full narration transcript is inline below. Every line is timestamped in \
HH:MM:SS,mmm from the start of the video, so each spoken instruction maps to a specific \
moment on screen.\n\n",
);
s.push_str("## How to use the video\n");
s.push_str(
"- If you can ingest video directly, watch `recording.mp4` and follow along with the \
timestamps below.\n",
);
s.push_str(
"- Otherwise, pull a still frame at any timestamp with ffmpeg. `start_ms` is in \
milliseconds; ffmpeg `-ss` takes seconds, so divide by 1000:\n\n",
);
s.push_str(" ffmpeg -ss <seconds> -i recording.mp4 -frames:v 1 frame.png\n\n");
s.push_str(
" Example — to see what was on screen when the narrator spoke at start_ms 12500:\n\n",
);
s.push_str(" ffmpeg -ss 12.500 -i recording.mp4 -frames:v 1 frame.png\n\n");
s.push_str(
"- Correlate words with actions: when the narration says \"open this menu\" at a given \
timestamp, extract the frame at that timestamp to see which menu.\n\n",
);
s.push_str("## Narration transcript (timestamps are HH:MM:SS,mmm from video start)\n");
if transcript.segments.is_empty() {
s.push_str("_(no narration transcript — rely on the video.)_\n\n");
} else {
for seg in &transcript.segments {
s.push_str(&format!(
"- [{} → {}] {}\n",
format_srt_timestamp(seg.start_ms),
format_srt_timestamp(seg.end_ms),
seg.text,
));
}
s.push('\n');
}
s.push_str("## Your task\n");
s.push_str(
"Follow the narrated instructions above in order. Where an instruction is visual \
(\"this\", \"here\", \"that button\"), use the timestamp to locate the on-screen target in \
the video before acting.\n",
);
s
}
fn human_duration(ms: u64) -> String {
let secs = ms as f64 / 1000.0;
if secs < 60.0 {
format!("{secs:.1}s")
} else {
let total = (secs.round()) as u64;
format!("{}m {}s", total / 60, total % 60)
}
}
fn fmt_fps(fps: f32) -> String {
if (fps.fract()).abs() < f32::EPSILON {
format!("{}", fps as i64)
} else {
format!("{fps:.2}")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transcript::TranscriptSegment;
fn sample_manifest(recording: &Recording, transcript: &Transcript) -> RecordingManifest {
RecordingManifest::new(
recording,
&Target::ByTitleRegex("My Game".into()),
"cli",
VideoMeta {
path: files::VIDEO.into(),
container: "mp4".into(),
codec: "h264".into(),
fps: 30.0,
width: 1920,
height: 1080,
duration_ms: 83_000,
},
Some(AudioMeta {
path: files::AUDIO.into(),
sample_rate: 48_000,
channels: 1,
duration_ms: 83_000,
}),
transcript,
"command",
Some("whisper-cli".into()),
recording.started_at,
)
}
#[test]
fn human_duration_formats() {
assert_eq!(human_duration(8_500), "8.5s");
assert_eq!(human_duration(83_000), "1m 23s");
}
#[test]
fn prompt_embeds_transcript_and_ffmpeg_recipe() {
let started = DateTime::parse_from_rfc3339("2026-06-14T06:22:17Z")
.unwrap()
.with_timezone(&Utc);
let rec = Recording::new(Path::new("/tmp"), started, "Game.exe");
let transcript = Transcript {
language: Some("en".into()),
duration_ms: 4800,
segments: vec![TranscriptSegment {
start_ms: 1250,
end_ms: 4800,
text: "open the settings panel".into(),
}],
};
let manifest = sample_manifest(&rec, &transcript);
let prompt = render_prompt(&manifest, &transcript);
assert!(prompt.contains("frames:v 1 frame.png"));
assert!(prompt.contains("-ss 12.500"));
assert!(prompt.contains("[00:00:01,250 → 00:00:04,800] open the settings panel"));
assert!(prompt.contains("\"My Game\""));
}
#[test]
fn empty_transcript_prompt_has_no_narration_line() {
let started = Utc::now();
let rec = Recording::new(Path::new("/tmp"), started, "Game.exe");
let transcript = Transcript::default();
let manifest = sample_manifest(&rec, &transcript);
let prompt = render_prompt(&manifest, &transcript);
assert!(prompt.contains("no narration transcript"));
}
#[test]
fn manifest_roundtrips_and_records_engine() {
let started = Utc::now();
let rec = Recording::new(Path::new("/tmp"), started, "Game.exe");
let transcript = Transcript::default();
let manifest = sample_manifest(&rec, &transcript);
assert_eq!(manifest.kind, "recording");
assert_eq!(manifest.transcript.engine, "command");
assert_eq!(manifest.artifacts.len(), 7);
let json = serde_json::to_string(&manifest).unwrap();
let back: RecordingManifest = serde_json::from_str(&json).unwrap();
assert_eq!(back.session_id, manifest.session_id);
assert_eq!(back.video.width, 1920);
}
}