1use std::io::Read as _;
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Serialize};
14
15use crate::artifact_kind::ArtifactKind;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum ChangeType {
25 Localized,
27 Tonal,
29 Structural,
31 Minor,
33 Identical,
35}
36
37impl std::fmt::Display for ChangeType {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 let s = match self {
40 ChangeType::Localized => "localized",
41 ChangeType::Tonal => "tonal",
42 ChangeType::Structural => "structural",
43 ChangeType::Minor => "minor",
44 ChangeType::Identical => "identical",
45 };
46 write!(f, "{}", s)
47 }
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct AssetDiffSummary {
53 pub text: String,
55 pub change_type: ChangeType,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct AssetSupervisorVerdict {
62 pub confidence: f32,
64 pub match_assessment: String,
66 pub flags: Vec<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct AssetDiffConfig {
73 pub enabled: bool,
75 pub visual_diff: bool,
77 pub visual_diff_threshold: f32,
79 pub supervisor: bool,
81 pub agent: String,
83 pub timeout_secs: u64,
85}
86
87impl Default for AssetDiffConfig {
88 fn default() -> Self {
89 Self {
90 enabled: true,
91 visual_diff: false,
92 visual_diff_threshold: 0.3,
93 supervisor: true,
94 agent: "builtin".to_string(),
95 timeout_secs: 60,
96 }
97 }
98}
99
100#[derive(Debug, Clone)]
102pub struct VisualDiffOutput {
103 pub diff_path: PathBuf,
105 pub diff_type: VisualDiffType,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum VisualDiffType {
112 CropComparison,
114 ColorBar,
116 KeyframeSummary,
118}
119
120#[derive(Debug)]
122pub struct AssetDiffResult {
123 pub summary: Option<AssetDiffSummary>,
125 pub supervisor: Option<AssetSupervisorVerdict>,
127 pub visual_diff: Option<VisualDiffOutput>,
129 pub skipped_reason: Option<String>,
131}
132
133#[derive(Deserialize, Debug)]
138struct RawDiffResponse {
139 text: Option<String>,
140 change_type: Option<String>,
141}
142
143#[derive(Deserialize, Debug)]
144struct RawSupervisorResponse {
145 confidence: Option<f64>,
146 match_assessment: Option<String>,
147 flags: Option<Vec<String>>,
148}
149
150pub fn run_asset_diff(
159 before_path: &Path,
160 after_path: &Path,
161 kind: &ArtifactKind,
162 goal_intent: &str,
163 config: &AssetDiffConfig,
164 staging_dir: &Path,
165) -> AssetDiffResult {
166 if !config.enabled {
167 return AssetDiffResult {
168 summary: None,
169 supervisor: None,
170 visual_diff: None,
171 skipped_reason: Some("asset diff disabled in config".to_string()),
172 };
173 }
174
175 let summary_result = run_diff_summary_agent(before_path, after_path, kind, config);
177 let summary = match summary_result {
178 Ok(s) => s,
179 Err(e) => {
180 tracing::warn!(error = %e, "DiffSummaryAgent failed — skipping asset diff");
181 return AssetDiffResult {
182 summary: None,
183 supervisor: None,
184 visual_diff: None,
185 skipped_reason: Some(format!("diff summary agent failed: {}", e)),
186 };
187 }
188 };
189
190 let supervisor_verdict = if config.supervisor {
192 match run_supervisor_agent(goal_intent, &summary, config) {
193 Ok(v) => Some(v),
194 Err(e) => {
195 tracing::warn!(error = %e, "AssetSupervisorAgent failed — skipping supervisor verdict");
196 None
197 }
198 }
199 } else {
200 None
201 };
202
203 let visual_diff_output = if config.visual_diff {
205 match VisualDiffRenderer::render(
206 before_path,
207 after_path,
208 &summary.change_type,
209 kind,
210 staging_dir,
211 ) {
212 Ok(v) => Some(v),
213 Err(e) => {
214 tracing::warn!(error = %e, "VisualDiffRenderer failed");
215 None
216 }
217 }
218 } else {
219 None
220 };
221
222 AssetDiffResult {
223 summary: Some(summary),
224 supervisor: supervisor_verdict,
225 visual_diff: visual_diff_output,
226 skipped_reason: None,
227 }
228}
229
230fn build_diff_summary_prompt(before_path: &Path, after_path: &Path, kind: &ArtifactKind) -> String {
235 let kind_label = kind.display_label();
236 format!(
237 r#"You are reviewing a visual asset change. Describe ONLY what you observe visually — do not speculate about intent.
238
239Artifact: {kind_label} at {after_path}
240Before: {before}
241After: {after}
242
243Respond with JSON:
244{{
245 "text": "one or two sentence description of what visually changed",
246 "change_type": "localized|tonal|structural|minor|identical"
247}}
248
249Use:
250- "localized": change is confined to a region of the image/frame
251- "tonal": color, brightness, or contrast shifted globally
252- "structural": major rearrangement of scene elements
253- "minor": small, difficult-to-notice change
254- "identical": files appear visually identical
255
256Respond with ONLY the JSON object."#,
257 kind_label = kind_label,
258 after_path = after_path.display(),
259 before = before_path.display(),
260 after = after_path.display(),
261 )
262}
263
264fn run_diff_summary_agent(
265 before_path: &Path,
266 after_path: &Path,
267 kind: &ArtifactKind,
268 config: &AssetDiffConfig,
269) -> anyhow::Result<AssetDiffSummary> {
270 let prompt = build_diff_summary_prompt(before_path, after_path, kind);
271 let stdout = invoke_agent_cli(&prompt, config)?;
272 let text = extract_claude_stream_json_text(&stdout);
273 Ok(parse_diff_summary_from_json(&text))
274}
275
276pub fn build_diff_summary_from_json(json_str: &str) -> AssetDiffSummary {
281 parse_diff_summary_from_json(json_str)
282}
283
284fn parse_diff_summary_from_json(text: &str) -> AssetDiffSummary {
285 let json_str = extract_json(text);
286 if let Ok(raw) = serde_json::from_str::<RawDiffResponse>(json_str) {
287 let change_type = match raw.change_type.as_deref() {
288 Some("localized") => ChangeType::Localized,
289 Some("tonal") => ChangeType::Tonal,
290 Some("structural") => ChangeType::Structural,
291 Some("identical") => ChangeType::Identical,
292 _ => ChangeType::Minor,
293 };
294 let summary_text = raw
295 .text
296 .unwrap_or_else(|| "Agent did not describe the change.".to_string());
297 return AssetDiffSummary {
298 text: summary_text,
299 change_type,
300 };
301 }
302 let summary_text = if text.trim().is_empty() {
304 "Agent returned empty response.".to_string()
305 } else if text.len() > 300 {
306 format!("{}…", &text[..300])
307 } else {
308 text.trim().to_string()
309 };
310 AssetDiffSummary {
311 text: summary_text,
312 change_type: ChangeType::Minor,
313 }
314}
315
316fn build_supervisor_prompt(goal_intent: &str, diff_summary: &AssetDiffSummary) -> String {
321 format!(
322 r#"Goal intent: {goal_intent}
323
324Asset diff summary: {summary}
325Change type: {change_type}
326
327Cross-check whether this diff is consistent with the stated goal intent.
328
329Respond with JSON:
330{{
331 "confidence": 0.0-1.0,
332 "match_assessment": "one sentence assessment",
333 "flags": ["concern 1", ...]
334}}
335
336Use:
337- confidence 1.0: diff is fully consistent with the goal intent
338- confidence 0.7-0.99: mostly consistent with minor uncertainty
339- confidence below 0.7: significant mismatch or concern
340
341Respond with ONLY the JSON object."#,
342 goal_intent = goal_intent,
343 summary = diff_summary.text,
344 change_type = diff_summary.change_type,
345 )
346}
347
348fn run_supervisor_agent(
349 goal_intent: &str,
350 diff_summary: &AssetDiffSummary,
351 config: &AssetDiffConfig,
352) -> anyhow::Result<AssetSupervisorVerdict> {
353 let prompt = build_supervisor_prompt(goal_intent, diff_summary);
354 let stdout = invoke_agent_cli(&prompt, config)?;
355 let text = extract_claude_stream_json_text(&stdout);
356 Ok(parse_supervisor_verdict_from_json(&text))
357}
358
359pub fn parse_supervisor_verdict_from_json(text: &str) -> AssetSupervisorVerdict {
361 let json_str = extract_json(text);
362 if let Ok(raw) = serde_json::from_str::<RawSupervisorResponse>(json_str) {
363 let confidence = raw
364 .confidence
365 .map(|v| v.clamp(0.0, 1.0) as f32)
366 .unwrap_or(0.5);
367 let match_assessment = raw
368 .match_assessment
369 .unwrap_or_else(|| "No assessment provided.".to_string());
370 let flags = raw.flags.unwrap_or_default();
371 return AssetSupervisorVerdict {
372 confidence,
373 match_assessment,
374 flags,
375 };
376 }
377 AssetSupervisorVerdict {
379 confidence: 0.5,
380 match_assessment: "Could not parse supervisor response.".to_string(),
381 flags: vec![],
382 }
383}
384
385pub struct VisualDiffRenderer;
390
391impl VisualDiffRenderer {
392 pub fn render(
398 before_path: &Path,
399 after_path: &Path,
400 change_type: &ChangeType,
401 kind: &ArtifactKind,
402 staging_dir: &Path,
403 ) -> anyhow::Result<VisualDiffOutput> {
404 let diffs_dir = staging_dir.join("diffs");
405 std::fs::create_dir_all(&diffs_dir)?;
406
407 let stem = after_path
408 .file_stem()
409 .and_then(|s| s.to_str())
410 .unwrap_or("asset");
411
412 let (diff_type, suffix) = if kind.is_video() {
413 (VisualDiffType::KeyframeSummary, "_keyframes.txt")
414 } else {
415 match change_type {
416 ChangeType::Localized => (VisualDiffType::CropComparison, "_crop.txt"),
417 ChangeType::Tonal => (VisualDiffType::ColorBar, "_colordiff.txt"),
418 _ => (VisualDiffType::CropComparison, "_diff.txt"),
419 }
420 };
421
422 let diff_filename = format!("{}{}", stem, suffix);
423 let diff_path = diffs_dir.join(&diff_filename);
424
425 let content = format!(
426 "Visual diff placeholder (v0.15.4)\n\
427 Before: {}\n\
428 After: {}\n\
429 Type: {:?}\n\
430 \n\
431 Note: Full image/video diff rendering requires a vision-capable viewer.\n\
432 Review the before/after files directly to verify the change.\n",
433 before_path.display(),
434 after_path.display(),
435 diff_type,
436 );
437
438 std::fs::write(&diff_path, content)?;
439
440 Ok(VisualDiffOutput {
441 diff_path,
442 diff_type,
443 })
444 }
445}
446
447fn invoke_agent_cli(prompt: &str, config: &AssetDiffConfig) -> anyhow::Result<String> {
453 let binary = match config.agent.as_str() {
454 "builtin" | "claude-code" => "claude",
455 other => other,
456 };
457 spawn_with_timeout(
458 binary,
459 &[
460 "--print",
461 "--verbose",
462 "--output-format",
463 "stream-json",
464 prompt,
465 ],
466 config.timeout_secs,
467 &format!("{} CLI", config.agent),
468 )
469}
470
471fn spawn_with_timeout(
474 program: &str,
475 args: &[&str],
476 timeout_secs: u64,
477 label: &str,
478) -> anyhow::Result<String> {
479 let mut child = std::process::Command::new(program)
480 .args(args)
481 .stdout(std::process::Stdio::piped())
482 .stderr(std::process::Stdio::piped())
483 .spawn()
484 .map_err(|e| {
485 anyhow::anyhow!(
486 "Failed to spawn '{}': {} — is {} installed and on PATH?",
487 program,
488 e,
489 label
490 )
491 })?;
492
493 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
494
495 loop {
496 match child.try_wait() {
497 Ok(Some(status)) => {
498 let mut stdout = String::new();
499 if let Some(mut out) = child.stdout.take() {
500 let _ = out.read_to_string(&mut stdout);
501 }
502 if !status.success() && stdout.trim().is_empty() {
503 let mut stderr = String::new();
504 if let Some(mut err) = child.stderr.take() {
505 let _ = err.read_to_string(&mut stderr);
506 }
507 anyhow::bail!(
508 "{} exited with status {}: {}",
509 label,
510 status,
511 &stderr[..stderr.len().min(200)]
512 );
513 }
514 return Ok(stdout);
515 }
516 Ok(None) => {
517 if std::time::Instant::now() >= deadline {
518 let _ = child.kill();
519 anyhow::bail!(
520 "{} timed out after {}s — increase [draft.asset_diff] timeout_secs in workflow.toml",
521 label,
522 timeout_secs
523 );
524 }
525 std::thread::sleep(std::time::Duration::from_millis(200));
526 }
527 Err(e) => {
528 anyhow::bail!("Error waiting for {}: {}", label, e);
529 }
530 }
531 }
532}
533
534fn extract_claude_stream_json_text(stdout: &str) -> String {
537 for line in stdout.lines().rev() {
538 let line = line.trim();
539 if line.is_empty() {
540 continue;
541 }
542 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
543 continue;
544 };
545 if val.get("type").and_then(|t| t.as_str()) == Some("result") {
546 if let Some(text) = val.get("result").and_then(|r| r.as_str()) {
547 if !text.trim().is_empty() {
548 return text.to_string();
549 }
550 }
551 if let Some(content) = val.get("content") {
552 let text = extract_content_text(content);
553 if !text.is_empty() {
554 return text;
555 }
556 }
557 }
558 }
559 for line in stdout.lines().rev() {
560 let line = line.trim();
561 if line.is_empty() {
562 continue;
563 }
564 let Ok(val) = serde_json::from_str::<serde_json::Value>(line) else {
565 continue;
566 };
567 if val.get("type").and_then(|t| t.as_str()) == Some("assistant") {
568 if let Some(content) = val.get("message").and_then(|m| m.get("content")) {
569 let text = extract_content_text(content);
570 if !text.is_empty() {
571 return text;
572 }
573 }
574 }
575 }
576 stdout.to_string()
577}
578
579fn extract_content_text(content: &serde_json::Value) -> String {
580 if let Some(arr) = content.as_array() {
581 arr.iter()
582 .filter_map(|item| {
583 if item.get("type").and_then(|t| t.as_str()) == Some("text") {
584 item.get("text")
585 .and_then(|t| t.as_str())
586 .map(|s| s.to_string())
587 } else {
588 None
589 }
590 })
591 .collect::<Vec<_>>()
592 .join("")
593 } else {
594 content.as_str().unwrap_or("").to_string()
595 }
596}
597
598fn extract_json(text: &str) -> &str {
601 if let Some(start) = text.find("```json") {
602 let after = &text[start + 7..];
603 if let Some(end) = after.find("```") {
604 return after[..end].trim();
605 }
606 }
607 if let Some(start) = text.find("```") {
608 let after = &text[start + 3..];
609 if let Some(end) = after.find("```") {
610 return after[..end].trim();
611 }
612 }
613 if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) {
615 if start < end {
616 return &text[start..=end];
617 }
618 }
619 text.trim()
620}
621
622#[cfg(test)]
627mod tests {
628 use super::*;
629 use tempfile::tempdir;
630
631 #[test]
633 fn diff_summary_from_json_tonal() {
634 let json = r#"{"text": "Lighting shifted from warm to cool.", "change_type": "tonal"}"#;
635 let summary = build_diff_summary_from_json(json);
636 assert_eq!(summary.change_type, ChangeType::Tonal);
637 assert!(summary.text.contains("warm"));
638 }
639
640 #[test]
642 fn diff_summary_from_json_localized() {
643 let json = r#"{"text": "Shadow deepened in the left corner.", "change_type": "localized"}"#;
644 let summary = build_diff_summary_from_json(json);
645 assert_eq!(summary.change_type, ChangeType::Localized);
646 assert!(summary.text.contains("corner"));
647 }
648
649 #[test]
651 fn diff_summary_unknown_change_type_falls_back_to_minor() {
652 let json = r#"{"text": "Something changed.", "change_type": "cosmic"}"#;
653 let summary = build_diff_summary_from_json(json);
654 assert_eq!(summary.change_type, ChangeType::Minor);
655 }
656
657 #[test]
659 fn diff_summary_non_json_fallback() {
660 let text = "The image changed.";
661 let summary = build_diff_summary_from_json(text);
662 assert_eq!(summary.change_type, ChangeType::Minor);
663 assert!(!summary.text.is_empty());
664 }
665
666 #[test]
668 fn supervisor_high_confidence_for_match() {
669 let json = r#"{
670 "confidence": 0.95,
671 "match_assessment": "Consistent with goal to adjust day/night lighting.",
672 "flags": []
673 }"#;
674 let verdict = parse_supervisor_verdict_from_json(json);
675 assert!(
676 verdict.confidence > 0.7,
677 "confidence: {}",
678 verdict.confidence
679 );
680 assert!(verdict.flags.is_empty());
681 }
682
683 #[test]
685 fn supervisor_low_confidence_for_mismatch() {
686 let json = r#"{
687 "confidence": 0.42,
688 "match_assessment": "The change affects character position, not lighting.",
689 "flags": ["Character moved left — goal only mentioned color adjustment."]
690 }"#;
691 let verdict = parse_supervisor_verdict_from_json(json);
692 assert!(
693 verdict.confidence < 0.7,
694 "confidence: {}",
695 verdict.confidence
696 );
697 assert!(!verdict.flags.is_empty());
698 }
699
700 #[test]
702 fn supervisor_confidence_clamped() {
703 let json = r#"{"confidence": 1.5, "match_assessment": "ok", "flags": []}"#;
704 let verdict = parse_supervisor_verdict_from_json(json);
705 assert!(verdict.confidence <= 1.0);
706
707 let json2 = r#"{"confidence": -0.5, "match_assessment": "bad", "flags": []}"#;
708 let verdict2 = parse_supervisor_verdict_from_json(json2);
709 assert!(verdict2.confidence >= 0.0);
710 }
711
712 #[test]
714 fn visual_diff_renderer_produces_path() {
715 let dir = tempdir().unwrap();
716 let before = dir.path().join("frame_before.png");
717 let after = dir.path().join("frame_after.png");
718 std::fs::write(&before, b"before").unwrap();
719 std::fs::write(&after, b"after").unwrap();
720
721 let kind = ArtifactKind::Image {
722 width: Some(1024),
723 height: Some(1024),
724 format: Some("PNG".to_string()),
725 frame_index: None,
726 };
727
728 let result =
729 VisualDiffRenderer::render(&before, &after, &ChangeType::Tonal, &kind, dir.path())
730 .unwrap();
731
732 assert!(
733 result.diff_path.to_str().unwrap().contains("_colordiff"),
734 "expected colordiff suffix, got: {}",
735 result.diff_path.display()
736 );
737 assert_eq!(result.diff_type, VisualDiffType::ColorBar);
738 assert!(result.diff_path.exists(), "diff file should be written");
739 }
740
741 #[test]
743 fn visual_diff_renderer_localized_produces_crop() {
744 let dir = tempdir().unwrap();
745 let before = dir.path().join("img.png");
746 let after = dir.path().join("img.png");
747 std::fs::write(&before, b"x").unwrap();
748
749 let kind = ArtifactKind::Image {
750 width: None,
751 height: None,
752 format: None,
753 frame_index: None,
754 };
755
756 let result =
757 VisualDiffRenderer::render(&before, &after, &ChangeType::Localized, &kind, dir.path())
758 .unwrap();
759
760 assert_eq!(result.diff_type, VisualDiffType::CropComparison);
761 assert!(
762 result.diff_path.to_str().unwrap().contains("_crop"),
763 "got: {}",
764 result.diff_path.display()
765 );
766 }
767
768 #[test]
770 fn visual_diff_renderer_video_keyframe() {
771 let dir = tempdir().unwrap();
772 let before = dir.path().join("clip.mp4");
773 let after = dir.path().join("clip.mp4");
774 std::fs::write(&before, b"x").unwrap();
775
776 let kind = ArtifactKind::Video {
777 width: None,
778 height: None,
779 fps: None,
780 duration_secs: None,
781 format: Some("MP4".to_string()),
782 frame_count: None,
783 };
784
785 let result =
786 VisualDiffRenderer::render(&before, &after, &ChangeType::Structural, &kind, dir.path())
787 .unwrap();
788
789 assert_eq!(result.diff_type, VisualDiffType::KeyframeSummary);
790 assert!(
791 result.diff_path.to_str().unwrap().contains("_keyframes"),
792 "got: {}",
793 result.diff_path.display()
794 );
795 }
796
797 #[test]
799 fn asset_diff_config_defaults() {
800 let cfg = AssetDiffConfig::default();
801 assert!(cfg.enabled);
802 assert!(!cfg.visual_diff);
803 assert!((cfg.visual_diff_threshold - 0.3).abs() < f32::EPSILON);
804 assert!(cfg.supervisor);
805 assert_eq!(cfg.agent, "builtin");
806 assert_eq!(cfg.timeout_secs, 60);
807 }
808
809 #[test]
811 fn asset_diff_config_serde_roundtrip() {
812 let cfg = AssetDiffConfig {
813 enabled: false,
814 visual_diff: true,
815 visual_diff_threshold: 0.5,
816 supervisor: false,
817 agent: "codex".to_string(),
818 timeout_secs: 120,
819 };
820 let json = serde_json::to_string(&cfg).unwrap();
821 let back: AssetDiffConfig = serde_json::from_str(&json).unwrap();
822 assert!(!back.enabled);
823 assert!(back.visual_diff);
824 assert!(!back.supervisor);
825 assert_eq!(back.agent, "codex");
826 assert_eq!(back.timeout_secs, 120);
827 }
828
829 #[test]
831 fn run_asset_diff_skipped_when_disabled() {
832 let dir = tempdir().unwrap();
833 let before = dir.path().join("img.png");
834 let after = dir.path().join("img.png");
835 std::fs::write(&before, b"x").unwrap();
836
837 let kind = ArtifactKind::Image {
838 width: None,
839 height: None,
840 format: None,
841 frame_index: None,
842 };
843 let cfg = AssetDiffConfig {
844 enabled: false,
845 ..AssetDiffConfig::default()
846 };
847
848 let result = run_asset_diff(&before, &after, &kind, "adjust lighting", &cfg, dir.path());
849
850 assert!(result.summary.is_none());
851 assert!(result.supervisor.is_none());
852 assert!(result.visual_diff.is_none());
853 assert_eq!(
854 result.skipped_reason.as_deref(),
855 Some("asset diff disabled in config")
856 );
857 }
858
859 #[test]
861 fn extract_json_from_markdown_fence() {
862 let text = "Sure, here is the result:\n```json\n{\"text\": \"changed\", \"change_type\": \"tonal\"}\n```";
863 let summary = build_diff_summary_from_json(text);
864 assert_eq!(summary.change_type, ChangeType::Tonal);
865 assert_eq!(summary.text, "changed");
866 }
867}