use super::*;
#[derive(Debug, Clone)]
pub(crate) struct ImageRef {
pub(crate) session_id: String,
pub(crate) is_subagent: bool,
pub(crate) parent_session_id: String,
pub(crate) line_no: usize,
pub(crate) img_index: usize,
pub(crate) seq: Option<usize>,
pub(crate) fingerprint: String,
pub(crate) source_kind: String,
pub(crate) media_type: String,
pub(crate) b64_len: usize,
pub(crate) est_bytes: usize,
pub(crate) url: Option<String>,
pub(crate) ts_utc: Option<String>,
pub(crate) record_uuid: Option<String>,
pub(crate) data: Option<String>,
}
impl ImageRef {
pub(crate) fn id(&self) -> String {
format!("L{}i{}", self.line_no, self.img_index)
}
pub(crate) fn handle(&self) -> String {
match self.seq {
Some(n) => format!("#{n}"),
None => self.id(),
}
}
pub(crate) fn ext(&self) -> &'static str {
match self.media_type.as_str() {
"image/png" => "png",
"image/jpeg" | "image/jpg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
"image/svg+xml" => "svg",
"image/bmp" => "bmp",
"image/tiff" => "tiff",
"image/heic" => "heic",
"image/avif" => "avif",
_ => "bin",
}
}
pub(crate) fn out_filename_with_ext(&self, ext: &str) -> String {
let short = self.session_id.get(..8).unwrap_or(&self.session_id);
match self.seq {
Some(n) => format!("{short}-img{n}-{}.{ext}", self.id()),
None => format!("{short}-{}.{ext}", self.id()),
}
}
}
pub(crate) fn line_is_image_candidate(line: &[u8]) -> bool {
static NEEDLES: std::sync::LazyLock<[memmem::Finder<'static>; 3]> =
std::sync::LazyLock::new(|| {
[
memmem::Finder::new(br#""type":"image""#),
memmem::Finder::new(br#""media_type""#),
memmem::Finder::new(b"base64"),
]
});
NEEDLES.iter().any(|f| f.find(line).is_some())
}
pub(crate) fn human_bytes(n: usize) -> String {
if n >= 1024 * 1024 {
format!("{:.1} MB", n as f64 / (1024.0 * 1024.0))
} else if n >= 1024 {
format!("{} KB", n / 1024)
} else {
format!("{n} B")
}
}
pub(crate) fn est_decoded_len(b64: &str) -> usize {
let pad = b64
.as_bytes()
.iter()
.rev()
.take_while(|&&c| c == b'=')
.count();
(b64.len() / 4) * 3 - pad.min(2)
}
pub(crate) fn decode_base64(s: &str) -> Option<Vec<u8>> {
pub(crate) fn sextet(c: u8) -> Option<u32> {
match c {
b'A'..=b'Z' => Some((c - b'A') as u32),
b'a'..=b'z' => Some((c - b'a' + 26) as u32),
b'0'..=b'9' => Some((c - b'0' + 52) as u32),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let mut out = Vec::with_capacity(s.len() / 4 * 3 + 3);
let mut buf = 0u32;
let mut bits = 0u32;
for &c in s.as_bytes() {
if c == b'=' || c.is_ascii_whitespace() {
continue;
}
buf = (buf << 6) | sextet(c)?;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
}
}
Some(out)
}
pub(crate) fn image_ref_from_source(source: &Value, with_data: bool) -> Option<ImageRef> {
let source_kind = source
.get("type")
.and_then(Value::as_str)
.unwrap_or("base64")
.to_string();
let media_type = source
.get("media_type")
.and_then(Value::as_str)
.unwrap_or("application/octet-stream")
.to_string();
let (b64_len, est_bytes, url, fingerprint, data) = if source_kind == "url" {
let url = source
.get("url")
.and_then(Value::as_str)
.map(str::to_string);
let fp = format!("url:{}", url.as_deref().unwrap_or(""));
(0, 0, url, fp, None)
} else {
let d = source.get("data").and_then(Value::as_str)?;
let head: String = d.chars().take(32).collect();
let tail: String = d.chars().rev().take(32).collect();
let fp = format!("{}:{head}:{tail}", d.len());
(
d.len(),
est_decoded_len(d),
None,
fp,
with_data.then(|| d.to_string()),
)
};
Some(ImageRef {
session_id: String::new(),
is_subagent: false,
parent_session_id: String::new(),
line_no: 0,
img_index: 0,
seq: None,
fingerprint,
source_kind,
media_type,
b64_len,
est_bytes,
url,
ts_utc: None,
record_uuid: None,
data,
})
}
pub(crate) fn parse_image_markers(text: &str, out: &mut Vec<usize>) {
let mut rest = text;
const PAT: &str = "[Image #";
while let Some(i) = rest.find(PAT) {
let after = &rest[i + PAT.len()..];
let digits: String = after.chars().take_while(char::is_ascii_digit).collect();
if !digits.is_empty() && after[digits.len()..].starts_with(']') {
if let Ok(n) = digits.parse::<usize>() {
out.push(n);
}
}
rest = &after[digits.len()..];
}
}
pub(crate) fn record_images(rec: &Record, with_data: bool) -> Vec<ImageRef> {
let mut out = Vec::new();
let Some(blocks) = rec.blocks() else {
return queued_prompt_images(rec, with_data);
};
let mut markers: Vec<usize> = Vec::new();
for block in blocks {
if let Block::Text { text } = block {
parse_image_markers(text, &mut markers);
}
}
for block in blocks {
match block {
Block::Image { source: Some(src) } => {
if let Some(mut r) = image_ref_from_source(src, with_data) {
r.img_index = out.len() + 1;
out.push(r);
}
}
Block::ToolResult {
content: Some(content),
..
} => {
if let Some(arr) = content.as_array() {
for el in arr {
if el.get("type").and_then(Value::as_str) == Some("image") {
if let Some(src) = el.get("source") {
if let Some(mut r) = image_ref_from_source(src, with_data) {
r.img_index = out.len() + 1;
out.push(r);
}
}
}
}
}
}
_ => {}
}
}
if markers.len() == out.len() {
for (r, &n) in out.iter_mut().zip(markers.iter()) {
r.seq = Some(n);
}
}
out
}
fn queued_prompt_images(rec: &Record, with_data: bool) -> Vec<ImageRef> {
let mut out = Vec::new();
if rec.attachment_type().as_deref() != Some("queued_command") {
return out;
}
let Some(att) = rec.attachment_value() else {
return out;
};
let Some(prompt) = att.get("prompt").and_then(Value::as_array) else {
return out;
};
let mut markers: Vec<usize> = Vec::new();
for el in prompt {
if el.get("type").and_then(Value::as_str) == Some("text") {
if let Some(text) = el.get("text").and_then(Value::as_str) {
parse_image_markers(text, &mut markers);
}
}
}
for el in prompt {
if el.get("type").and_then(Value::as_str) == Some("image") {
if let Some(src) = el.get("source") {
if let Some(mut r) = image_ref_from_source(src, with_data) {
r.img_index = out.len() + 1;
out.push(r);
}
}
}
}
if markers.len() == out.len() {
for (r, &n) in out.iter_mut().zip(markers.iter()) {
r.seq = Some(n);
}
}
out
}
pub(crate) fn image_ids_for_record(rec: &Record, line_no: usize) -> Vec<String> {
record_images(rec, false)
.into_iter()
.map(|mut r| {
r.line_no = line_no;
r.handle()
})
.collect()
}
pub(crate) fn images_in_file(path: &Path, with_data: bool) -> Result<(Vec<ImageRef>, usize)> {
let session_id = crate::subagent::session_id_from_path(path);
let is_subagent = crate::subagent::is_subagent_path(path);
let parent_session_id =
crate::subagent::parent_session_id_from_path(path).unwrap_or_else(|| session_id.clone());
let Some(mmap) = mmap_bytes(path)? else {
return Ok((Vec::new(), 0));
};
let (records, skipped) = parse_candidates_parallel(&mmap, line_is_image_candidate);
let mut out = Vec::new();
for (line_no, rec) in &records {
for mut r in record_images(rec, with_data) {
r.session_id = session_id.clone();
r.is_subagent = is_subagent;
r.parent_session_id = parent_session_id.clone();
r.line_no = *line_no;
r.ts_utc = rec.timestamp.clone();
r.record_uuid = rec.uuid.clone();
out.push(r);
}
}
out.sort_by(|a, b| (a.line_no, a.img_index).cmp(&(b.line_no, b.img_index)));
Ok((out, skipped))
}