Skip to main content

dsfb_computer_graphics/
external.rs

1use std::fs;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::config::DemoConfig;
8use crate::error::{Error, Result};
9use crate::frame::{save_scalar_field_png, Color, ImageFrame, ScalarField};
10use crate::host::{
11    default_host_realistic_profile, supervise_temporal_reuse, HostSupervisionOutputs,
12    HostTemporalInputs,
13};
14use crate::report::EXPERIMENT_SENTENCE;
15use crate::scene::{
16    generate_sequence_for_definition, scenario_by_id, MotionVector, Normal3, ScenarioId,
17    SceneFrame, SceneSequence, SurfaceTag,
18};
19use crate::taa::run_fixed_alpha_baseline;
20
21pub const EXTERNAL_CAPTURE_FORMAT_VERSION: &str = "dsfb_external_capture_v1";
22pub const NO_REAL_EXTERNAL_DATA_PROVIDED: &str = "NO REAL EXTERNAL DATA PROVIDED";
23
24#[derive(Clone, Debug)]
25pub struct OwnedHostTemporalInputs {
26    pub current_color: ImageFrame,
27    pub reprojected_history: ImageFrame,
28    pub motion_vectors: Vec<MotionVector>,
29    pub current_depth: Vec<f32>,
30    pub reprojected_depth: Vec<f32>,
31    pub current_normals: Vec<Normal3>,
32    pub reprojected_normals: Vec<Normal3>,
33    pub visibility_hint: Option<Vec<bool>>,
34    pub thin_hint: Option<ScalarField>,
35}
36
37impl OwnedHostTemporalInputs {
38    pub fn width(&self) -> usize {
39        self.current_color.width()
40    }
41
42    pub fn height(&self) -> usize {
43        self.current_color.height()
44    }
45
46    pub fn borrow(&self) -> HostTemporalInputs<'_> {
47        HostTemporalInputs {
48            current_color: &self.current_color,
49            reprojected_history: &self.reprojected_history,
50            motion_vectors: &self.motion_vectors,
51            current_depth: &self.current_depth,
52            reprojected_depth: &self.reprojected_depth,
53            current_normals: &self.current_normals,
54            reprojected_normals: &self.reprojected_normals,
55            visibility_hint: self.visibility_hint.as_deref(),
56            thin_hint: self.thin_hint.as_ref(),
57        }
58    }
59}
60
61#[derive(Clone, Debug, Serialize, Deserialize)]
62pub struct BufferReference {
63    pub path: String,
64    pub format: String,
65    pub semantic: String,
66    #[serde(default)]
67    pub width: Option<usize>,
68    #[serde(default)]
69    pub height: Option<usize>,
70    #[serde(default)]
71    pub channels: Option<usize>,
72}
73
74#[derive(Clone, Debug, Serialize, Deserialize)]
75pub struct ExternalBufferSet {
76    pub current_color: BufferReference,
77    #[serde(rename = "history_color", alias = "reprojected_history")]
78    pub reprojected_history: BufferReference,
79    pub motion_vectors: BufferReference,
80    pub current_depth: BufferReference,
81    #[serde(rename = "history_depth", alias = "reprojected_depth")]
82    pub reprojected_depth: BufferReference,
83    pub current_normals: BufferReference,
84    #[serde(rename = "history_normals", alias = "reprojected_normals")]
85    pub reprojected_normals: BufferReference,
86    pub metadata: BufferReference,
87    pub optional_mask: Option<BufferReference>,
88    #[serde(default)]
89    pub optional_reference: Option<BufferReference>,
90    #[serde(default)]
91    pub optional_ground_truth: Option<BufferReference>,
92    #[serde(default)]
93    pub optional_variance: Option<BufferReference>,
94}
95
96#[derive(Clone, Debug, Serialize, Deserialize)]
97pub struct ExternalCaptureEntry {
98    pub label: String,
99    pub buffers: ExternalBufferSet,
100}
101
102#[derive(Clone, Debug, Serialize, Deserialize)]
103#[serde(tag = "kind", rename_all = "snake_case")]
104pub enum ExternalCaptureSource {
105    Files,
106    SyntheticCompat {
107        scenario_id: String,
108        frame_index: Option<usize>,
109    },
110    /// First-class engine-native capture path.
111    /// engine_type: "unreal" | "unity" | "custom" | "pending"
112    /// When engine_type == "pending" or buffer files are absent,
113    /// all downstream reports carry ENGINE_NATIVE_CAPTURE_MISSING=true.
114    EngineNative {
115        engine_type: String,
116        #[serde(default)]
117        engine_version: Option<String>,
118        #[serde(default)]
119        capture_tool: Option<String>,
120        #[serde(default)]
121        capture_note: Option<String>,
122    },
123}
124
125#[derive(Clone, Debug, Serialize, Deserialize)]
126pub struct ExternalNormalization {
127    pub color: String,
128    pub motion_vectors: String,
129    pub depth: String,
130    pub normals: String,
131}
132
133#[derive(Clone, Debug, Serialize, Deserialize)]
134pub struct ExternalCaptureManifest {
135    pub format_version: String,
136    pub description: String,
137    pub source: ExternalCaptureSource,
138    #[serde(default)]
139    pub buffers: Option<ExternalBufferSet>,
140    #[serde(default)]
141    pub captures: Vec<ExternalCaptureEntry>,
142    pub normalization: ExternalNormalization,
143    pub notes: Vec<String>,
144}
145
146#[derive(Clone, Debug, Serialize, Deserialize)]
147struct ScalarBufferFile {
148    width: usize,
149    height: usize,
150    data: Vec<f32>,
151}
152
153#[derive(Clone, Debug, Serialize, Deserialize)]
154struct Vec2BufferFile {
155    width: usize,
156    height: usize,
157    data: Vec<[f32; 2]>,
158}
159
160#[derive(Clone, Debug, Serialize, Deserialize)]
161struct Vec3BufferFile {
162    width: usize,
163    height: usize,
164    data: Vec<[f32; 3]>,
165}
166
167#[derive(Clone, Debug, Serialize, Deserialize)]
168struct BoolBufferFile {
169    width: usize,
170    height: usize,
171    data: Vec<bool>,
172}
173
174#[derive(Clone, Debug, Serialize, Deserialize)]
175struct ColorBufferFile {
176    width: usize,
177    height: usize,
178    data: Vec<[f32; 3]>,
179}
180
181#[derive(Clone, Debug, Serialize, Deserialize)]
182pub struct ExternalCaptureMetadata {
183    pub scenario_id: Option<String>,
184    pub frame_index: usize,
185    pub history_frame_index: usize,
186    pub width: usize,
187    pub height: usize,
188    pub source_kind: String,
189    pub externally_validated: bool,
190    #[serde(default)]
191    pub real_external_data: bool,
192    #[serde(default)]
193    pub data_description: Option<String>,
194    pub notes: Vec<String>,
195}
196
197#[derive(Clone, Debug, Serialize, Deserialize)]
198pub struct ExternalHandoffMetrics {
199    pub measurement_kind: String,
200    pub external_capable: bool,
201    pub externally_validated: bool,
202    pub real_external_data_provided: bool,
203    pub no_real_external_data_provided: bool,
204    pub source_kind: String,
205    pub scenario_id: Option<String>,
206    pub frame_index: usize,
207    pub history_frame_index: usize,
208    pub width: usize,
209    pub height: usize,
210    pub capture_count: usize,
211    pub imported_formats: Vec<String>,
212    pub required_buffers: Vec<String>,
213    pub normalization_notes: Vec<String>,
214    pub roi_source: String,
215    pub ground_truth_available: bool,
216    pub variance_available: bool,
217    pub mean_trust: f32,
218    pub mean_alpha: f32,
219    pub intervention_rate: f32,
220    pub notes: Vec<String>,
221}
222
223#[derive(Clone, Debug)]
224pub struct ExternalImportArtifacts {
225    pub report_path: PathBuf,
226    pub metrics: ExternalHandoffMetrics,
227    pub resolved_manifest_path: PathBuf,
228}
229
230#[derive(Clone, Debug)]
231pub struct ExternalLoadedCapture {
232    pub label: String,
233    pub inputs: OwnedHostTemporalInputs,
234    pub metadata: ExternalCaptureMetadata,
235    pub mask: Option<Vec<bool>>,
236    pub reference: Option<ImageFrame>,
237    pub variance: Option<ScalarField>,
238}
239
240#[derive(Clone, Debug)]
241pub struct ExternalCaptureBundle {
242    pub manifest: ExternalCaptureManifest,
243    pub captures: Vec<ExternalLoadedCapture>,
244    pub real_external_data_provided: bool,
245    pub no_real_external_data_provided: bool,
246}
247
248pub fn example_manifest() -> ExternalCaptureManifest {
249    ExternalCaptureManifest {
250        format_version: EXTERNAL_CAPTURE_FORMAT_VERSION.to_string(),
251        description: "Synthetic compatibility example that exports one frame pair into the stable external buffer schema and re-imports it through the same file-based path.".to_string(),
252        source: ExternalCaptureSource::SyntheticCompat {
253            scenario_id: "motion_bias_band".to_string(),
254            frame_index: None,
255        },
256        buffers: Some(ExternalBufferSet {
257            current_color: BufferReference {
258                path: "external_capture/current_color.png".to_string(),
259                format: "png_rgb8".to_string(),
260                semantic: "current color, normalized [0,1] RGB".to_string(),
261                width: None,
262                height: None,
263                channels: None,
264            },
265            reprojected_history: BufferReference {
266                path: "external_capture/reprojected_history.png".to_string(),
267                format: "png_rgb8".to_string(),
268                semantic: "reprojected history color, normalized [0,1] RGB".to_string(),
269                width: None,
270                height: None,
271                channels: None,
272            },
273            motion_vectors: BufferReference {
274                path: "external_capture/motion_vectors.json".to_string(),
275                format: "json_vec2_f32".to_string(),
276                semantic: "per-pixel motion vector to previous frame in pixel units".to_string(),
277                width: None,
278                height: None,
279                channels: None,
280            },
281            current_depth: BufferReference {
282                path: "external_capture/current_depth.json".to_string(),
283                format: "json_scalar_f32".to_string(),
284                semantic: "current frame depth".to_string(),
285                width: None,
286                height: None,
287                channels: None,
288            },
289            reprojected_depth: BufferReference {
290                path: "external_capture/reprojected_depth.json".to_string(),
291                format: "json_scalar_f32".to_string(),
292                semantic: "reprojected depth from previous frame".to_string(),
293                width: None,
294                height: None,
295                channels: None,
296            },
297            current_normals: BufferReference {
298                path: "external_capture/current_normals.json".to_string(),
299                format: "json_vec3_f32".to_string(),
300                semantic: "current frame normals in view space, unit length".to_string(),
301                width: None,
302                height: None,
303                channels: None,
304            },
305            reprojected_normals: BufferReference {
306                path: "external_capture/reprojected_normals.json".to_string(),
307                format: "json_vec3_f32".to_string(),
308                semantic: "reprojected normals from previous frame".to_string(),
309                width: None,
310                height: None,
311                channels: None,
312            },
313            metadata: BufferReference {
314                path: "external_capture/metadata.json".to_string(),
315                format: "json_metadata".to_string(),
316                semantic: "capture metadata and provenance".to_string(),
317                width: None,
318                height: None,
319                channels: None,
320            },
321            optional_mask: Some(BufferReference {
322                path: "external_capture/optional_mask.json".to_string(),
323                format: "json_mask_bool".to_string(),
324                semantic: "optional ROI-like disclosure or debug mask".to_string(),
325                width: None,
326                height: None,
327                channels: None,
328            }),
329            optional_reference: Some(BufferReference {
330                path: "external_capture/optional_reference.png".to_string(),
331                format: "png_rgb8".to_string(),
332                semantic: "optional reference frame for evaluator-side error checks; synthetic in the bundled example".to_string(),
333                width: None,
334                height: None,
335                channels: None,
336            }),
337            optional_ground_truth: None,
338            optional_variance: None,
339        }),
340        captures: Vec::new(),
341        normalization: ExternalNormalization {
342            color: "linear RGB in [0,1]".to_string(),
343            motion_vectors: "pixel offsets to the previous frame; positive x samples from a pixel further right in history".to_string(),
344            depth: "monotonic depth with larger disagreement indicating less trust".to_string(),
345            normals: "unit vectors in a consistent view-space basis".to_string(),
346        },
347        notes: vec![
348            "Switch source.kind from synthetic_compat to files when real engine exports are available.".to_string(),
349            "The example capture is external-capable but not externally validated.".to_string(),
350            NO_REAL_EXTERNAL_DATA_PROVIDED.to_string(),
351        ],
352    }
353}
354
355pub fn write_example_manifest(path: &Path) -> Result<()> {
356    if let Some(parent) = path.parent() {
357        fs::create_dir_all(parent)?;
358    }
359    fs::write(path, serde_json::to_string_pretty(&example_manifest())?)?;
360    Ok(())
361}
362
363pub fn build_owned_inputs_from_sequence(
364    sequence: &SceneSequence,
365    frame_index: usize,
366    previous_history: Option<&ImageFrame>,
367) -> Result<OwnedHostTemporalInputs> {
368    let frame_index = frame_index.min(sequence.frames.len().saturating_sub(1));
369    if frame_index == 0 {
370        return Err(Error::Message(
371            "external capture requires a frame index after the first frame".to_string(),
372        ));
373    }
374
375    let scene_frame = &sequence.frames[frame_index];
376    let previous_scene = &sequence.frames[frame_index - 1];
377    let history_source = previous_history.unwrap_or(&previous_scene.ground_truth);
378    let reprojected_history = reproject_frame(history_source, scene_frame);
379    let reprojected_depth = reproject_depth(previous_scene, scene_frame);
380    let reprojected_normals = reproject_normals(previous_scene, scene_frame);
381
382    Ok(OwnedHostTemporalInputs {
383        current_color: scene_frame.ground_truth.clone(),
384        reprojected_history,
385        motion_vectors: scene_frame.motion.clone(),
386        current_depth: scene_frame.depth.clone(),
387        reprojected_depth,
388        current_normals: scene_frame.normals.clone(),
389        reprojected_normals,
390        visibility_hint: None,
391        thin_hint: None,
392    })
393}
394
395pub fn run_external_import_from_manifest(
396    config: &DemoConfig,
397    manifest_path: &Path,
398    output_dir: &Path,
399) -> Result<ExternalImportArtifacts> {
400    fs::create_dir_all(output_dir)?;
401    let bundle = load_external_capture_bundle(config, manifest_path, output_dir)?;
402    let first_capture = bundle
403        .captures
404        .first()
405        .ok_or_else(|| Error::Message("external capture bundle had no captures".to_string()))?;
406    let first_buffers = first_capture_buffer_set(&bundle.manifest)?;
407    let resolved_manifest_path = output_dir.join("resolved_external_capture_manifest.json");
408    fs::write(
409        &resolved_manifest_path,
410        serde_json::to_string_pretty(&bundle.manifest)?,
411    )?;
412
413    let profile =
414        default_host_realistic_profile(config.dsfb_alpha_range.min, config.dsfb_alpha_range.max);
415    let outputs = supervise_temporal_reuse(&first_capture.inputs.borrow(), &profile);
416    write_external_outputs(output_dir, &first_capture.inputs, &outputs)?;
417
418    let no_real_external_data_provided =
419        bundle.no_real_external_data_provided || !bundle.real_external_data_provided;
420    let mut notes = first_capture.metadata.notes.clone();
421    if no_real_external_data_provided {
422        notes.push(NO_REAL_EXTERNAL_DATA_PROVIDED.to_string());
423    }
424
425    let metrics = ExternalHandoffMetrics {
426        measurement_kind: if bundle.real_external_data_provided {
427            "external_buffer_import_real".to_string()
428        } else {
429            "external_buffer_import_external_ready".to_string()
430        },
431        external_capable: true,
432        externally_validated: first_capture.metadata.externally_validated,
433        real_external_data_provided: bundle.real_external_data_provided,
434        no_real_external_data_provided,
435        source_kind: first_capture.metadata.source_kind.clone(),
436        scenario_id: first_capture.metadata.scenario_id.clone(),
437        frame_index: first_capture.metadata.frame_index,
438        history_frame_index: first_capture.metadata.history_frame_index,
439        width: first_capture.metadata.width,
440        height: first_capture.metadata.height,
441        capture_count: bundle.captures.len(),
442        imported_formats: vec![
443            first_buffers.current_color.format.clone(),
444            first_buffers.reprojected_history.format.clone(),
445            first_buffers.motion_vectors.format.clone(),
446            first_buffers.current_depth.format.clone(),
447            first_buffers.current_normals.format.clone(),
448        ],
449        required_buffers: vec![
450            "current_color".to_string(),
451            "reprojected_history".to_string(),
452            "motion_vectors".to_string(),
453            "current_depth".to_string(),
454            "reprojected_depth".to_string(),
455            "current_normals".to_string(),
456            "reprojected_normals".to_string(),
457        ],
458        normalization_notes: vec![
459            bundle.manifest.normalization.color.clone(),
460            bundle.manifest.normalization.motion_vectors.clone(),
461            bundle.manifest.normalization.depth.clone(),
462            bundle.manifest.normalization.normals.clone(),
463        ],
464        roi_source: if first_capture.mask.is_some() {
465            "manifest_mask".to_string()
466        } else {
467            "derived_proxy_mask".to_string()
468        },
469        ground_truth_available: first_capture.reference.is_some(),
470        variance_available: first_capture.variance.is_some(),
471        mean_trust: outputs.trust.mean(),
472        mean_alpha: outputs.alpha.mean(),
473        intervention_rate: outputs.intervention.mean(),
474        notes,
475    };
476
477    let report_path = output_dir.join("external_replay_report.md");
478    let handoff_alias_path = output_dir.join("external_handoff_report.md");
479    write_external_replay_report(&report_path, &metrics, &bundle.manifest)?;
480    fs::copy(&report_path, &handoff_alias_path)?;
481
482    Ok(ExternalImportArtifacts {
483        report_path,
484        metrics,
485        resolved_manifest_path,
486    })
487}
488
489pub fn load_external_capture_bundle(
490    config: &DemoConfig,
491    manifest_path: &Path,
492    output_dir: &Path,
493) -> Result<ExternalCaptureBundle> {
494    let manifest_text = fs::read_to_string(manifest_path)?;
495    let manifest: ExternalCaptureManifest = serde_json::from_str(&manifest_text)?;
496    if manifest.format_version != EXTERNAL_CAPTURE_FORMAT_VERSION {
497        return Err(Error::Message(format!(
498            "unsupported external capture format version {}",
499            manifest.format_version
500        )));
501    }
502
503    let resolved_manifest = match &manifest.source {
504        ExternalCaptureSource::Files => manifest.clone(),
505        ExternalCaptureSource::SyntheticCompat {
506            scenario_id,
507            frame_index,
508        } => {
509            let scenario_id = parse_scenario_id(scenario_id)?;
510            let definition = scenario_by_id(&config.scene, scenario_id).ok_or_else(|| {
511                Error::Message(format!(
512                    "synthetic compat scenario {scenario_id:?} not found"
513                ))
514            })?;
515            let sequence = generate_sequence_for_definition(&definition);
516            let export_frame_index = frame_index.unwrap_or(
517                definition
518                    .onset_frame
519                    .min(sequence.frames.len().saturating_sub(1)),
520            );
521            let fixed_alpha = run_fixed_alpha_baseline(&sequence, config.baseline.fixed_alpha);
522            let previous_history = fixed_alpha.taa.resolved_frames.get(export_frame_index - 1);
523            let inputs =
524                build_owned_inputs_from_sequence(&sequence, export_frame_index, previous_history)?;
525            let metadata = ExternalCaptureMetadata {
526                scenario_id: Some(sequence.scenario_id.as_str().to_string()),
527                frame_index: export_frame_index,
528                history_frame_index: export_frame_index.saturating_sub(1),
529                width: inputs.width(),
530                height: inputs.height(),
531                source_kind: "synthetic_compat".to_string(),
532                externally_validated: false,
533                real_external_data: false,
534                data_description: Some(
535                    "Deterministic synthetic compatibility export generated inside the crate"
536                        .to_string(),
537                ),
538                notes: vec![
539                    "The example external capture was generated from the crate's deterministic synthetic suite.".to_string(),
540                    "Replace source.kind with files and point the buffer paths at real engine exports to move beyond synthetic compatibility.".to_string(),
541                    NO_REAL_EXTERNAL_DATA_PROVIDED.to_string(),
542                ],
543            };
544            let capture_mask =
545                compute_external_compatible_mask(&sequence.frames[export_frame_index]);
546            materialize_capture_bundle(
547                &manifest,
548                output_dir,
549                &inputs,
550                &metadata,
551                Some(&capture_mask),
552                Some(&inputs.current_color),
553            )?
554        }
555        // Engine-native captures use real file-based buffers — treat like Files.
556        ExternalCaptureSource::EngineNative { .. } => manifest.clone(),
557    };
558
559    let captures = load_capture_entries(&resolved_manifest, manifest_path, output_dir)?;
560    let real_external_data_provided = captures
561        .iter()
562        .any(|capture| capture.metadata.real_external_data);
563    Ok(ExternalCaptureBundle {
564        manifest: resolved_manifest,
565        captures,
566        real_external_data_provided,
567        no_real_external_data_provided: !real_external_data_provided,
568    })
569}
570
571fn materialize_capture_bundle(
572    manifest: &ExternalCaptureManifest,
573    base_dir: &Path,
574    inputs: &OwnedHostTemporalInputs,
575    metadata: &ExternalCaptureMetadata,
576    optional_mask: Option<&[bool]>,
577    optional_reference: Option<&ImageFrame>,
578) -> Result<ExternalCaptureManifest> {
579    let buffer_set = manifest.buffers.clone().ok_or_else(|| {
580        Error::Message("synthetic compat manifest requires a top-level buffers block".to_string())
581    })?;
582    write_color_buffer(base_dir, &buffer_set.current_color, &inputs.current_color)?;
583    write_color_buffer(
584        base_dir,
585        &buffer_set.reprojected_history,
586        &inputs.reprojected_history,
587    )?;
588    write_vec2_buffer(
589        base_dir,
590        &buffer_set.motion_vectors,
591        &inputs.motion_vectors,
592        inputs.width(),
593        inputs.height(),
594    )?;
595    write_scalar_buffer(
596        base_dir,
597        &buffer_set.current_depth,
598        &inputs.current_depth,
599        inputs.width(),
600        inputs.height(),
601    )?;
602    write_scalar_buffer(
603        base_dir,
604        &buffer_set.reprojected_depth,
605        &inputs.reprojected_depth,
606        inputs.width(),
607        inputs.height(),
608    )?;
609    write_vec3_buffer(
610        base_dir,
611        &buffer_set.current_normals,
612        &inputs.current_normals,
613        inputs.width(),
614        inputs.height(),
615    )?;
616    write_vec3_buffer(
617        base_dir,
618        &buffer_set.reprojected_normals,
619        &inputs.reprojected_normals,
620        inputs.width(),
621        inputs.height(),
622    )?;
623    if let Some(mask_ref) = &buffer_set.optional_mask {
624        let fallback_mask;
625        let mask_values = if let Some(values) = optional_mask {
626            values
627        } else {
628            fallback_mask = vec![false; inputs.width() * inputs.height()];
629            &fallback_mask
630        };
631        write_bool_buffer(
632            base_dir,
633            mask_ref,
634            mask_values,
635            inputs.width(),
636            inputs.height(),
637        )?;
638    }
639    if let Some(reference_ref) = &buffer_set
640        .optional_ground_truth
641        .as_ref()
642        .or(buffer_set.optional_reference.as_ref())
643    {
644        if let Some(reference_frame) = optional_reference {
645            write_color_buffer(base_dir, reference_ref, reference_frame)?;
646        }
647    }
648    let metadata_path = base_dir.join(&buffer_set.metadata.path);
649    if let Some(parent) = metadata_path.parent() {
650        fs::create_dir_all(parent)?;
651    }
652    fs::write(&metadata_path, serde_json::to_string_pretty(metadata)?)?;
653    let mut resolved = manifest.clone();
654    resolved.buffers = Some(buffer_set);
655    Ok(resolved)
656}
657
658fn load_capture_entries(
659    manifest: &ExternalCaptureManifest,
660    manifest_path: &Path,
661    synthetic_base_dir: &Path,
662) -> Result<Vec<ExternalLoadedCapture>> {
663    let entries = capture_entries(manifest)?;
664    match &manifest.source {
665        ExternalCaptureSource::Files => {
666            let base_dir = manifest_path.parent().ok_or_else(|| {
667                Error::Message("manifest path had no parent directory".to_string())
668            })?;
669            entries
670                .iter()
671                .map(|(label, buffers)| {
672                    load_single_capture(base_dir, label, buffers, &manifest.normalization)
673                })
674                .collect()
675        }
676        ExternalCaptureSource::SyntheticCompat { .. } => entries
677            .iter()
678            .map(|(label, buffers)| {
679                load_single_capture(synthetic_base_dir, label, buffers, &manifest.normalization)
680            })
681            .collect(),
682        // Engine-native: load from manifest file's parent directory (same as Files).
683        ExternalCaptureSource::EngineNative { .. } => {
684            let base_dir = manifest_path.parent().ok_or_else(|| {
685                Error::Message("manifest path had no parent directory".to_string())
686            })?;
687            entries
688                .iter()
689                .map(|(label, buffers)| {
690                    load_single_capture(base_dir, label, buffers, &manifest.normalization)
691                })
692                .collect()
693        }
694    }
695}
696
697fn capture_entries(manifest: &ExternalCaptureManifest) -> Result<Vec<(String, ExternalBufferSet)>> {
698    if !manifest.captures.is_empty() {
699        return Ok(manifest
700            .captures
701            .iter()
702            .map(|entry| (entry.label.clone(), entry.buffers.clone()))
703            .collect());
704    }
705    if let Some(buffers) = &manifest.buffers {
706        return Ok(vec![("capture_0".to_string(), buffers.clone())]);
707    }
708    Err(Error::Message(
709        "external capture manifest must provide either `buffers` for one frame pair or `captures` for a short sequence".to_string(),
710    ))
711}
712
713fn first_capture_buffer_set(manifest: &ExternalCaptureManifest) -> Result<ExternalBufferSet> {
714    capture_entries(manifest)?
715        .into_iter()
716        .next()
717        .map(|(_, buffers)| buffers)
718        .ok_or_else(|| Error::Message("external manifest had no buffer set".to_string()))
719}
720
721fn load_single_capture(
722    base_dir: &Path,
723    label: &str,
724    buffers: &ExternalBufferSet,
725    normalization: &ExternalNormalization,
726) -> Result<ExternalLoadedCapture> {
727    let metadata = load_metadata(base_dir, &buffers.metadata, false)?;
728    let inputs = load_owned_inputs(buffers, base_dir, metadata.width, metadata.height)?;
729    if metadata.width != inputs.width() || metadata.height != inputs.height() {
730        return Err(Error::Message(format!(
731            "metadata extent {}x{} did not match imported buffers {}x{}",
732            metadata.width,
733            metadata.height,
734            inputs.width(),
735            inputs.height()
736        )));
737    }
738    validate_normalization(label, &inputs, normalization)?;
739    let mask = buffers
740        .optional_mask
741        .as_ref()
742        .map(|reference| load_bool_buffer(base_dir, reference, metadata.width, metadata.height))
743        .transpose()?
744        .map(|file| file.data);
745    let reference = buffers
746        .optional_ground_truth
747        .as_ref()
748        .or(buffers.optional_reference.as_ref())
749        .map(|reference| load_color_buffer(base_dir, reference, metadata.width, metadata.height))
750        .transpose()?;
751    let variance = buffers
752        .optional_variance
753        .as_ref()
754        .map(|reference| {
755            load_scalar_buffer(base_dir, reference, metadata.width, metadata.height)
756                .map(|file| ScalarField::from_values(metadata.width, metadata.height, file.data))
757        })
758        .transpose()?;
759
760    Ok(ExternalLoadedCapture {
761        label: label.to_string(),
762        inputs,
763        metadata,
764        mask,
765        reference,
766        variance,
767    })
768}
769
770fn write_external_outputs(
771    output_dir: &Path,
772    inputs: &OwnedHostTemporalInputs,
773    outputs: &HostSupervisionOutputs,
774) -> Result<()> {
775    inputs
776        .current_color
777        .save_png(&output_dir.join("external_current_color.png"))?;
778    inputs
779        .reprojected_history
780        .save_png(&output_dir.join("external_reprojected_history.png"))?;
781    save_scalar_field_png(
782        &outputs.trust,
783        &output_dir.join("external_trust.png"),
784        |value| {
785            let v = (value.clamp(0.0, 1.0) * 255.0).round() as u8;
786            [v, v, 255, 255]
787        },
788    )?;
789    save_scalar_field_png(
790        &outputs.alpha,
791        &output_dir.join("external_alpha.png"),
792        |value| {
793            let v = (value.clamp(0.0, 1.0) * 255.0).round() as u8;
794            [255, v, 0, 255]
795        },
796    )?;
797    save_scalar_field_png(
798        &outputs.intervention,
799        &output_dir.join("external_intervention.png"),
800        |value| {
801            let v = (value.clamp(0.0, 1.0) * 255.0).round() as u8;
802            [255, 32, v, 255]
803        },
804    )?;
805    Ok(())
806}
807
808fn write_external_replay_report(
809    path: &Path,
810    metrics: &ExternalHandoffMetrics,
811    manifest: &ExternalCaptureManifest,
812) -> Result<()> {
813    let mut markdown = String::new();
814    markdown.push_str("# External Replay Report\n\n");
815    markdown.push_str(EXPERIMENT_SENTENCE);
816    markdown.push_str("\n\n");
817    markdown.push_str("This report covers the file-based external buffer replay path. It demonstrates that the crate is external-capable, not externally validated.\n\n");
818    markdown.push_str(&format!(
819        "Source kind: `{}`. Externally validated: `{}`. Real external data provided: `{}`.\n\n",
820        metrics.source_kind, metrics.externally_validated, metrics.real_external_data_provided
821    ));
822    if metrics.no_real_external_data_provided {
823        markdown.push_str(NO_REAL_EXTERNAL_DATA_PROVIDED);
824        markdown.push_str("\n\n");
825    }
826    markdown.push_str("## Required Buffers\n\n");
827    for buffer in &metrics.required_buffers {
828        markdown.push_str(&format!("- `{buffer}`\n"));
829    }
830    markdown.push_str("\n## Accepted Formats\n\n");
831    markdown.push_str("- `png_rgb8`\n");
832    markdown.push_str("- `json_rgb_f32`\n");
833    markdown.push_str("- `exr_rgb32f`\n");
834    markdown.push_str("- `json_scalar_f32`\n");
835    markdown.push_str("- `exr_r32f`\n");
836    markdown.push_str("- `raw_r32f` with inline width/height/channels = 1\n");
837    markdown.push_str("- `json_vec2_f32`\n");
838    markdown.push_str("- `exr_rg32f`\n");
839    markdown.push_str("- `raw_rg32f` with inline width/height/channels >= 2\n");
840    markdown.push_str("- `json_vec3_f32`\n");
841    markdown.push_str("- `raw_rgb32f` with inline width/height/channels >= 3\n");
842    markdown.push_str("- `json_mask_bool`\n");
843    markdown.push_str("- `raw_mask_u8` with inline width/height/channels = 1\n");
844    markdown.push_str("- `json_metadata`\n\n");
845    markdown.push_str("## Normalization Conventions\n\n");
846    for note in &metrics.normalization_notes {
847        markdown.push_str(&format!("- {note}\n"));
848    }
849    markdown.push_str("\n## Imported Capture Summary\n\n");
850    markdown.push_str(&format!(
851        "- Resolution: {}x{}\n- Frame index: {}\n- History frame index: {}\n- Mean trust: {:.4}\n- Mean alpha: {:.4}\n- Mean intervention: {:.4}\n",
852        metrics.width,
853        metrics.height,
854        metrics.frame_index,
855        metrics.history_frame_index,
856        metrics.mean_trust,
857        metrics.mean_alpha,
858        metrics.intervention_rate
859    ));
860    markdown.push_str("\n## How An Engine Team Would Use This\n\n");
861    markdown.push_str("- Export one frame pair using the buffer names and normalization described in the manifest.\n");
862    markdown.push_str(
863        "- Set `source.kind` to `files` and point the buffer paths at the exported assets.\n",
864    );
865    markdown.push_str("- Run `cargo run --release -- run-external-replay --manifest <manifest> --output <dir>`.\n");
866    markdown.push_str(
867        "- Alias: `cargo run --release -- replay-external --manifest <manifest> --output <dir>`.\n",
868    );
869    markdown.push_str("- Inspect `external_trust.png`, `external_alpha.png`, and `external_intervention.png` plus the generated report.\n\n");
870    markdown.push_str("## What Is Not Proven\n\n");
871    markdown.push_str("- This report does not claim any real engine capture has been validated unless the metadata says so.\n");
872    markdown.push_str("- The example manifest included in the crate is synthetic compatibility data, not field data.\n\n");
873    markdown.push_str("## Remaining Blockers\n\n");
874    markdown.push_str("- A real renderer still needs to export buffers into this schema.\n");
875    markdown.push_str("- Real production captures and engine motion vectors are still required for external validation.\n");
876    markdown.push_str("- If the GPU external report is unmeasured on the evaluator machine, imported-capture GPU timing still remains future work there.\n\n");
877    markdown.push_str("## Manifest Notes\n\n");
878    for note in &manifest.notes {
879        markdown.push_str(&format!("- {note}\n"));
880    }
881    fs::write(path, markdown)?;
882    Ok(())
883}
884
885fn load_owned_inputs(
886    buffers: &ExternalBufferSet,
887    base_dir: &Path,
888    expected_width: usize,
889    expected_height: usize,
890) -> Result<OwnedHostTemporalInputs> {
891    let current_color = load_color_buffer(
892        base_dir,
893        &buffers.current_color,
894        expected_width,
895        expected_height,
896    )?;
897    let reprojected_history = load_color_buffer(
898        base_dir,
899        &buffers.reprojected_history,
900        expected_width,
901        expected_height,
902    )?;
903    let width = current_color.width();
904    let height = current_color.height();
905    let motion_vectors = load_vec2_buffer(
906        base_dir,
907        &buffers.motion_vectors,
908        expected_width,
909        expected_height,
910    )?;
911    validate_buffer_extent(
912        "motion_vectors",
913        motion_vectors.width,
914        motion_vectors.height,
915        width,
916        height,
917    )?;
918    let current_depth = load_scalar_buffer(
919        base_dir,
920        &buffers.current_depth,
921        expected_width,
922        expected_height,
923    )?;
924    validate_buffer_extent(
925        "current_depth",
926        current_depth.width,
927        current_depth.height,
928        width,
929        height,
930    )?;
931    let reprojected_depth = load_scalar_buffer(
932        base_dir,
933        &buffers.reprojected_depth,
934        expected_width,
935        expected_height,
936    )?;
937    validate_buffer_extent(
938        "reprojected_depth",
939        reprojected_depth.width,
940        reprojected_depth.height,
941        width,
942        height,
943    )?;
944    let current_normals = load_vec3_buffer(
945        base_dir,
946        &buffers.current_normals,
947        expected_width,
948        expected_height,
949    )?;
950    validate_buffer_extent(
951        "current_normals",
952        current_normals.width,
953        current_normals.height,
954        width,
955        height,
956    )?;
957    let reprojected_normals = load_vec3_buffer(
958        base_dir,
959        &buffers.reprojected_normals,
960        expected_width,
961        expected_height,
962    )?;
963    validate_buffer_extent(
964        "reprojected_normals",
965        reprojected_normals.width,
966        reprojected_normals.height,
967        width,
968        height,
969    )?;
970    let optional_mask = buffers
971        .optional_mask
972        .as_ref()
973        .map(|reference| load_bool_buffer(base_dir, reference, expected_width, expected_height))
974        .transpose()?;
975    if let Some(mask) = &optional_mask {
976        validate_buffer_extent("optional_mask", mask.width, mask.height, width, height)?;
977    }
978    if let Some(reference) = buffers
979        .optional_ground_truth
980        .as_ref()
981        .or(buffers.optional_reference.as_ref())
982    {
983        let optional_reference =
984            load_color_buffer(base_dir, reference, expected_width, expected_height)?;
985        validate_buffer_extent(
986            "optional_reference",
987            optional_reference.width(),
988            optional_reference.height(),
989            width,
990            height,
991        )?;
992    }
993    Ok(OwnedHostTemporalInputs {
994        current_color,
995        reprojected_history,
996        motion_vectors: motion_vectors
997            .data
998            .into_iter()
999            .map(|value| MotionVector {
1000                to_prev_x: value[0],
1001                to_prev_y: value[1],
1002            })
1003            .collect(),
1004        current_depth: current_depth.data,
1005        reprojected_depth: reprojected_depth.data,
1006        current_normals: current_normals
1007            .data
1008            .into_iter()
1009            .map(|value| Normal3::new(value[0], value[1], value[2]).normalized())
1010            .collect(),
1011        reprojected_normals: reprojected_normals
1012            .data
1013            .into_iter()
1014            .map(|value| Normal3::new(value[0], value[1], value[2]).normalized())
1015            .collect(),
1016        visibility_hint: optional_mask.map(|mask| mask.data),
1017        thin_hint: None,
1018    })
1019}
1020
1021fn load_metadata(
1022    base_dir: &Path,
1023    reference: &BufferReference,
1024    externally_validated: bool,
1025) -> Result<ExternalCaptureMetadata> {
1026    if reference.format != "json_metadata" {
1027        return Err(Error::Message(format!(
1028            "metadata buffer {} must use json_metadata format",
1029            reference.path
1030        )));
1031    }
1032    let path = base_dir.join(&reference.path);
1033    let text = fs::read_to_string(path)?;
1034    let mut metadata: ExternalCaptureMetadata = serde_json::from_str(&text)?;
1035    metadata.externally_validated = externally_validated || metadata.externally_validated;
1036    if metadata.width == 0 || metadata.height == 0 {
1037        return Err(Error::Message(format!(
1038            "metadata {} declared zero-sized capture {}x{}",
1039            reference.path, metadata.width, metadata.height
1040        )));
1041    }
1042    if metadata.history_frame_index > metadata.frame_index {
1043        return Err(Error::Message(format!(
1044            "metadata {} had history_frame_index {} after frame_index {}",
1045            reference.path, metadata.history_frame_index, metadata.frame_index
1046        )));
1047    }
1048    Ok(metadata)
1049}
1050
1051fn load_color_buffer(
1052    base_dir: &Path,
1053    reference: &BufferReference,
1054    expected_width: usize,
1055    expected_height: usize,
1056) -> Result<ImageFrame> {
1057    let path = base_dir.join(&reference.path);
1058    let frame = match reference.format.as_str() {
1059        "png_rgb8" => ImageFrame::load_png(&path)?,
1060        "exr_rgb32f" => load_exr_color(&path)?,
1061        "raw_rgb32f" => load_raw_color(&path, reference, expected_width, expected_height)?,
1062        "json_rgb_f32" => {
1063            let file: ColorBufferFile = serde_json::from_str(&fs::read_to_string(path)?)?;
1064            ImageFrame::from_pixels(
1065                file.width,
1066                file.height,
1067                file.data
1068                    .into_iter()
1069                    .map(|rgb| Color::rgb(rgb[0], rgb[1], rgb[2]))
1070                    .collect(),
1071            )
1072        }
1073        other => {
1074            return Err(Error::Message(format!(
1075                "unsupported color buffer format {other}"
1076            )))
1077        }
1078    };
1079    validate_buffer_extent(
1080        "color_buffer",
1081        frame.width(),
1082        frame.height(),
1083        expected_width,
1084        expected_height,
1085    )?;
1086    Ok(frame)
1087}
1088
1089pub(crate) fn load_color_buffer_from_reference(
1090    base_dir: &Path,
1091    reference: &BufferReference,
1092    expected_width: usize,
1093    expected_height: usize,
1094) -> Result<ImageFrame> {
1095    load_color_buffer(base_dir, reference, expected_width, expected_height)
1096}
1097
1098fn load_scalar_buffer(
1099    base_dir: &Path,
1100    reference: &BufferReference,
1101    expected_width: usize,
1102    expected_height: usize,
1103) -> Result<ScalarBufferFile> {
1104    let path = base_dir.join(&reference.path);
1105    let file = match reference.format.as_str() {
1106        "json_scalar_f32" => serde_json::from_str(&fs::read_to_string(path)?)?,
1107        "exr_r32f" => load_exr_scalar(&path)?,
1108        "raw_r32f" => load_raw_scalar(&path, reference, expected_width, expected_height)?,
1109        other => {
1110            return Err(Error::Message(format!(
1111                "unsupported scalar buffer format {other}"
1112            )))
1113        }
1114    };
1115    validate_element_count("scalar_buffer", file.width, file.height, file.data.len())?;
1116    validate_buffer_extent(
1117        "scalar_buffer",
1118        file.width,
1119        file.height,
1120        expected_width,
1121        expected_height,
1122    )?;
1123    Ok(file)
1124}
1125
1126pub(crate) fn load_scalar_buffer_from_reference(
1127    base_dir: &Path,
1128    reference: &BufferReference,
1129    expected_width: usize,
1130    expected_height: usize,
1131) -> Result<(usize, usize, Vec<f32>)> {
1132    let file = load_scalar_buffer(base_dir, reference, expected_width, expected_height)?;
1133    Ok((file.width, file.height, file.data))
1134}
1135
1136fn load_vec2_buffer(
1137    base_dir: &Path,
1138    reference: &BufferReference,
1139    expected_width: usize,
1140    expected_height: usize,
1141) -> Result<Vec2BufferFile> {
1142    let path = base_dir.join(&reference.path);
1143    let file = match reference.format.as_str() {
1144        "json_vec2_f32" => serde_json::from_str(&fs::read_to_string(path)?)?,
1145        "exr_rg32f" => load_exr_vec2(&path)?,
1146        "raw_rg32f" => load_raw_vec2(&path, reference, expected_width, expected_height)?,
1147        other => {
1148            return Err(Error::Message(format!(
1149                "unsupported vec2 buffer format {other}"
1150            )))
1151        }
1152    };
1153    validate_element_count("motion_vectors", file.width, file.height, file.data.len())?;
1154    validate_buffer_extent(
1155        "motion_vectors",
1156        file.width,
1157        file.height,
1158        expected_width,
1159        expected_height,
1160    )?;
1161    Ok(file)
1162}
1163
1164pub(crate) fn load_vec2_buffer_from_reference(
1165    base_dir: &Path,
1166    reference: &BufferReference,
1167    expected_width: usize,
1168    expected_height: usize,
1169) -> Result<(usize, usize, Vec<[f32; 2]>)> {
1170    let file = load_vec2_buffer(base_dir, reference, expected_width, expected_height)?;
1171    Ok((file.width, file.height, file.data))
1172}
1173
1174fn load_vec3_buffer(
1175    base_dir: &Path,
1176    reference: &BufferReference,
1177    expected_width: usize,
1178    expected_height: usize,
1179) -> Result<Vec3BufferFile> {
1180    let path = base_dir.join(&reference.path);
1181    let file = match reference.format.as_str() {
1182        "json_vec3_f32" => serde_json::from_str(&fs::read_to_string(path)?)?,
1183        "exr_rgb32f" => load_exr_vec3(&path)?,
1184        "raw_rgb32f" => load_raw_vec3(&path, reference, expected_width, expected_height)?,
1185        other => {
1186            return Err(Error::Message(format!(
1187                "unsupported vec3 buffer format {other}"
1188            )))
1189        }
1190    };
1191    validate_element_count("normal_buffer", file.width, file.height, file.data.len())?;
1192    validate_buffer_extent(
1193        "normal_buffer",
1194        file.width,
1195        file.height,
1196        expected_width,
1197        expected_height,
1198    )?;
1199    Ok(file)
1200}
1201
1202pub(crate) fn load_vec3_buffer_from_reference(
1203    base_dir: &Path,
1204    reference: &BufferReference,
1205    expected_width: usize,
1206    expected_height: usize,
1207) -> Result<(usize, usize, Vec<[f32; 3]>)> {
1208    let file = load_vec3_buffer(base_dir, reference, expected_width, expected_height)?;
1209    Ok((file.width, file.height, file.data))
1210}
1211
1212fn load_bool_buffer(
1213    base_dir: &Path,
1214    reference: &BufferReference,
1215    expected_width: usize,
1216    expected_height: usize,
1217) -> Result<BoolBufferFile> {
1218    let path = base_dir.join(&reference.path);
1219    let file = match reference.format.as_str() {
1220        "json_mask_bool" => serde_json::from_str(&fs::read_to_string(path)?)?,
1221        "raw_mask_u8" => load_raw_mask(&path, reference, expected_width, expected_height)?,
1222        other => {
1223            return Err(Error::Message(format!(
1224                "unsupported mask buffer format {other}"
1225            )))
1226        }
1227    };
1228    validate_element_count("optional_mask", file.width, file.height, file.data.len())?;
1229    validate_buffer_extent(
1230        "optional_mask",
1231        file.width,
1232        file.height,
1233        expected_width,
1234        expected_height,
1235    )?;
1236    Ok(file)
1237}
1238
1239pub(crate) fn load_bool_buffer_from_reference(
1240    base_dir: &Path,
1241    reference: &BufferReference,
1242    expected_width: usize,
1243    expected_height: usize,
1244) -> Result<(usize, usize, Vec<bool>)> {
1245    let file = load_bool_buffer(base_dir, reference, expected_width, expected_height)?;
1246    Ok((file.width, file.height, file.data))
1247}
1248
1249fn validate_buffer_extent(
1250    label: &str,
1251    width: usize,
1252    height: usize,
1253    expected_width: usize,
1254    expected_height: usize,
1255) -> Result<()> {
1256    if width != expected_width || height != expected_height {
1257        return Err(Error::Message(format!(
1258            "{label} extent {width}x{height} did not match expected {expected_width}x{expected_height}"
1259        )));
1260    }
1261    Ok(())
1262}
1263
1264fn validate_element_count(label: &str, width: usize, height: usize, count: usize) -> Result<()> {
1265    let expected = width.saturating_mul(height);
1266    if count != expected {
1267        return Err(Error::Message(format!(
1268            "{label} had {count} elements but expected {expected} for {width}x{height}"
1269        )));
1270    }
1271    Ok(())
1272}
1273
1274fn validate_normalization(
1275    label: &str,
1276    inputs: &OwnedHostTemporalInputs,
1277    normalization: &ExternalNormalization,
1278) -> Result<()> {
1279    if normalization.color.contains("[0,1]") || normalization.color.contains("linear RGB") {
1280        for (index, pixel) in inputs.current_color.pixels().iter().enumerate() {
1281            for channel in [pixel.r, pixel.g, pixel.b] {
1282                if !(-0.01..=1.01).contains(&channel) {
1283                    return Err(Error::Message(format!(
1284                        "{label} current_color pixel {index} violated normalized color expectations"
1285                    )));
1286                }
1287            }
1288        }
1289    }
1290
1291    for (index, depth) in inputs.current_depth.iter().enumerate() {
1292        if !depth.is_finite() {
1293            return Err(Error::Message(format!(
1294                "{label} current_depth[{index}] was non-finite"
1295            )));
1296        }
1297    }
1298    for (index, depth) in inputs.reprojected_depth.iter().enumerate() {
1299        if !depth.is_finite() {
1300            return Err(Error::Message(format!(
1301                "{label} reprojected_depth[{index}] was non-finite"
1302            )));
1303        }
1304    }
1305    for (index, motion) in inputs.motion_vectors.iter().enumerate() {
1306        if !motion.to_prev_x.is_finite()
1307            || !motion.to_prev_y.is_finite()
1308            || motion.to_prev_x.abs() > inputs.width() as f32 * 4.0
1309            || motion.to_prev_y.abs() > inputs.height() as f32 * 4.0
1310        {
1311            return Err(Error::Message(format!(
1312                "{label} motion_vectors[{index}] violated finite/range validation"
1313            )));
1314        }
1315    }
1316    if normalization.normals.contains("unit") {
1317        for (index, normal) in inputs.current_normals.iter().enumerate() {
1318            let norm = (normal.x * normal.x + normal.y * normal.y + normal.z * normal.z).sqrt();
1319            if !norm.is_finite() || (norm - 1.0).abs() > 0.05 {
1320                return Err(Error::Message(format!(
1321                    "{label} current_normals[{index}] violated unit-normal validation"
1322                )));
1323            }
1324        }
1325    }
1326    Ok(())
1327}
1328
1329fn load_exr_color(path: &Path) -> Result<ImageFrame> {
1330    let image = image::open(path)?.to_rgb32f();
1331    let width = image.width() as usize;
1332    let height = image.height() as usize;
1333    let pixels = image
1334        .pixels()
1335        .map(|pixel| Color::rgb(pixel[0], pixel[1], pixel[2]))
1336        .collect();
1337    Ok(ImageFrame::from_pixels(width, height, pixels))
1338}
1339
1340fn load_exr_scalar(path: &Path) -> Result<ScalarBufferFile> {
1341    let image = image::open(path)?.to_rgba32f();
1342    let width = image.width() as usize;
1343    let height = image.height() as usize;
1344    let data = image.pixels().map(|pixel| pixel[0]).collect();
1345    Ok(ScalarBufferFile {
1346        width,
1347        height,
1348        data,
1349    })
1350}
1351
1352fn load_exr_vec2(path: &Path) -> Result<Vec2BufferFile> {
1353    let image = image::open(path)?.to_rgba32f();
1354    let width = image.width() as usize;
1355    let height = image.height() as usize;
1356    let data = image.pixels().map(|pixel| [pixel[0], pixel[1]]).collect();
1357    Ok(Vec2BufferFile {
1358        width,
1359        height,
1360        data,
1361    })
1362}
1363
1364fn load_exr_vec3(path: &Path) -> Result<Vec3BufferFile> {
1365    let image = image::open(path)?.to_rgb32f();
1366    let width = image.width() as usize;
1367    let height = image.height() as usize;
1368    let data = image
1369        .pixels()
1370        .map(|pixel| [pixel[0], pixel[1], pixel[2]])
1371        .collect();
1372    Ok(Vec3BufferFile {
1373        width,
1374        height,
1375        data,
1376    })
1377}
1378
1379fn load_raw_color(
1380    path: &Path,
1381    reference: &BufferReference,
1382    expected_width: usize,
1383    expected_height: usize,
1384) -> Result<ImageFrame> {
1385    let (width, height) = extent_from_reference(reference, expected_width, expected_height)?;
1386    let channels = channel_count(reference, 3)?;
1387    if channels < 3 {
1388        return Err(Error::Message(format!(
1389            "raw_rgb32f buffer {} must provide at least 3 channels",
1390            reference.path
1391        )));
1392    }
1393    let values = read_raw_f32_values(path)?;
1394    validate_raw_value_count("raw_rgb32f", width, height, channels, values.len())?;
1395    let mut pixels = Vec::with_capacity(width * height);
1396    for chunk in values.chunks_exact(channels) {
1397        pixels.push(Color::rgb(chunk[0], chunk[1], chunk[2]));
1398    }
1399    Ok(ImageFrame::from_pixels(width, height, pixels))
1400}
1401
1402fn load_raw_scalar(
1403    path: &Path,
1404    reference: &BufferReference,
1405    expected_width: usize,
1406    expected_height: usize,
1407) -> Result<ScalarBufferFile> {
1408    let (width, height) = extent_from_reference(reference, expected_width, expected_height)?;
1409    let channels = channel_count(reference, 1)?;
1410    if channels != 1 {
1411        return Err(Error::Message(format!(
1412            "raw_r32f buffer {} must declare channels = 1",
1413            reference.path
1414        )));
1415    }
1416    let values = read_raw_f32_values(path)?;
1417    validate_raw_value_count("raw_r32f", width, height, channels, values.len())?;
1418    Ok(ScalarBufferFile {
1419        width,
1420        height,
1421        data: values,
1422    })
1423}
1424
1425fn load_raw_vec2(
1426    path: &Path,
1427    reference: &BufferReference,
1428    expected_width: usize,
1429    expected_height: usize,
1430) -> Result<Vec2BufferFile> {
1431    let (width, height) = extent_from_reference(reference, expected_width, expected_height)?;
1432    let channels = channel_count(reference, 2)?;
1433    if channels < 2 {
1434        return Err(Error::Message(format!(
1435            "raw_rg32f buffer {} must provide at least 2 channels",
1436            reference.path
1437        )));
1438    }
1439    let values = read_raw_f32_values(path)?;
1440    validate_raw_value_count("raw_rg32f", width, height, channels, values.len())?;
1441    let data = values
1442        .chunks_exact(channels)
1443        .map(|chunk| [chunk[0], chunk[1]])
1444        .collect();
1445    Ok(Vec2BufferFile {
1446        width,
1447        height,
1448        data,
1449    })
1450}
1451
1452fn load_raw_vec3(
1453    path: &Path,
1454    reference: &BufferReference,
1455    expected_width: usize,
1456    expected_height: usize,
1457) -> Result<Vec3BufferFile> {
1458    let (width, height) = extent_from_reference(reference, expected_width, expected_height)?;
1459    let channels = channel_count(reference, 3)?;
1460    if channels < 3 {
1461        return Err(Error::Message(format!(
1462            "raw_rgb32f buffer {} must provide at least 3 channels",
1463            reference.path
1464        )));
1465    }
1466    let values = read_raw_f32_values(path)?;
1467    validate_raw_value_count("raw_rgb32f", width, height, channels, values.len())?;
1468    let data = values
1469        .chunks_exact(channels)
1470        .map(|chunk| [chunk[0], chunk[1], chunk[2]])
1471        .collect();
1472    Ok(Vec3BufferFile {
1473        width,
1474        height,
1475        data,
1476    })
1477}
1478
1479fn load_raw_mask(
1480    path: &Path,
1481    reference: &BufferReference,
1482    expected_width: usize,
1483    expected_height: usize,
1484) -> Result<BoolBufferFile> {
1485    let (width, height) = extent_from_reference(reference, expected_width, expected_height)?;
1486    let channels = channel_count(reference, 1)?;
1487    if channels != 1 {
1488        return Err(Error::Message(format!(
1489            "raw_mask_u8 buffer {} must declare channels = 1",
1490            reference.path
1491        )));
1492    }
1493    let bytes = fs::read(path)?;
1494    let expected = width.saturating_mul(height);
1495    if bytes.len() != expected {
1496        return Err(Error::Message(format!(
1497            "raw_mask_u8 buffer {} had {} bytes but expected {} for {}x{}",
1498            reference.path,
1499            bytes.len(),
1500            expected,
1501            width,
1502            height
1503        )));
1504    }
1505    Ok(BoolBufferFile {
1506        width,
1507        height,
1508        data: bytes.into_iter().map(|value| value != 0).collect(),
1509    })
1510}
1511
1512fn extent_from_reference(
1513    reference: &BufferReference,
1514    expected_width: usize,
1515    expected_height: usize,
1516) -> Result<(usize, usize)> {
1517    let width = reference.width.unwrap_or(expected_width);
1518    let height = reference.height.unwrap_or(expected_height);
1519    if width == 0 || height == 0 {
1520        return Err(Error::Message(format!(
1521            "buffer {} must declare positive width/height either in metadata or inline",
1522            reference.path
1523        )));
1524    }
1525    Ok((width, height))
1526}
1527
1528fn channel_count(reference: &BufferReference, default_channels: usize) -> Result<usize> {
1529    let channels = reference.channels.unwrap_or(default_channels);
1530    if channels == 0 {
1531        return Err(Error::Message(format!(
1532            "buffer {} declared zero channels",
1533            reference.path
1534        )));
1535    }
1536    Ok(channels)
1537}
1538
1539fn validate_raw_value_count(
1540    label: &str,
1541    width: usize,
1542    height: usize,
1543    channels: usize,
1544    value_count: usize,
1545) -> Result<()> {
1546    let expected = width
1547        .checked_mul(height)
1548        .and_then(|pixels| pixels.checked_mul(channels))
1549        .ok_or_else(|| Error::Message(format!("{label} extent overflowed")))?;
1550    if value_count != expected {
1551        return Err(Error::Message(format!(
1552            "{label} had {value_count} float values but expected {expected} for {width}x{height}x{channels}"
1553        )));
1554    }
1555    Ok(())
1556}
1557
1558fn read_raw_f32_values(path: &Path) -> Result<Vec<f32>> {
1559    let mut file = fs::File::open(path)?;
1560    let mut bytes = Vec::new();
1561    file.read_to_end(&mut bytes)?;
1562    if bytes.len() % 4 != 0 {
1563        return Err(Error::Message(format!(
1564            "raw float buffer {} had {} bytes, which is not divisible by 4",
1565            path.display(),
1566            bytes.len()
1567        )));
1568    }
1569    Ok(bytes
1570        .chunks_exact(4)
1571        .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
1572        .collect())
1573}
1574
1575fn write_color_buffer(
1576    base_dir: &Path,
1577    reference: &BufferReference,
1578    frame: &ImageFrame,
1579) -> Result<()> {
1580    let path = base_dir.join(&reference.path);
1581    if let Some(parent) = path.parent() {
1582        fs::create_dir_all(parent)?;
1583    }
1584    match reference.format.as_str() {
1585        "png_rgb8" => frame.save_png(&path),
1586        "json_rgb_f32" => {
1587            let file = ColorBufferFile {
1588                width: frame.width(),
1589                height: frame.height(),
1590                data: frame
1591                    .pixels()
1592                    .iter()
1593                    .map(|pixel| [pixel.r, pixel.g, pixel.b])
1594                    .collect(),
1595            };
1596            fs::write(path, serde_json::to_string_pretty(&file)?)?;
1597            Ok(())
1598        }
1599        other => Err(Error::Message(format!(
1600            "unsupported color output format {other}"
1601        ))),
1602    }
1603}
1604
1605fn write_scalar_buffer(
1606    base_dir: &Path,
1607    reference: &BufferReference,
1608    values: &[f32],
1609    width: usize,
1610    height: usize,
1611) -> Result<()> {
1612    let path = base_dir.join(&reference.path);
1613    if let Some(parent) = path.parent() {
1614        fs::create_dir_all(parent)?;
1615    }
1616    let file = ScalarBufferFile {
1617        width,
1618        height,
1619        data: values.to_vec(),
1620    };
1621    fs::write(path, serde_json::to_string_pretty(&file)?)?;
1622    Ok(())
1623}
1624
1625fn write_vec2_buffer(
1626    base_dir: &Path,
1627    reference: &BufferReference,
1628    values: &[MotionVector],
1629    width: usize,
1630    height: usize,
1631) -> Result<()> {
1632    let path = base_dir.join(&reference.path);
1633    if let Some(parent) = path.parent() {
1634        fs::create_dir_all(parent)?;
1635    }
1636    let file = Vec2BufferFile {
1637        width,
1638        height,
1639        data: values
1640            .iter()
1641            .map(|value| [value.to_prev_x, value.to_prev_y])
1642            .collect(),
1643    };
1644    fs::write(path, serde_json::to_string_pretty(&file)?)?;
1645    Ok(())
1646}
1647
1648fn write_vec3_buffer(
1649    base_dir: &Path,
1650    reference: &BufferReference,
1651    values: &[Normal3],
1652    width: usize,
1653    height: usize,
1654) -> Result<()> {
1655    let path = base_dir.join(&reference.path);
1656    if let Some(parent) = path.parent() {
1657        fs::create_dir_all(parent)?;
1658    }
1659    let file = Vec3BufferFile {
1660        width,
1661        height,
1662        data: values
1663            .iter()
1664            .map(|value| [value.x, value.y, value.z])
1665            .collect(),
1666    };
1667    fs::write(path, serde_json::to_string_pretty(&file)?)?;
1668    Ok(())
1669}
1670
1671fn write_bool_buffer(
1672    base_dir: &Path,
1673    reference: &BufferReference,
1674    values: &[bool],
1675    width: usize,
1676    height: usize,
1677) -> Result<()> {
1678    let path = base_dir.join(&reference.path);
1679    if let Some(parent) = path.parent() {
1680        fs::create_dir_all(parent)?;
1681    }
1682    let file = BoolBufferFile {
1683        width,
1684        height,
1685        data: values.to_vec(),
1686    };
1687    fs::write(path, serde_json::to_string_pretty(&file)?)?;
1688    Ok(())
1689}
1690
1691pub fn parse_scenario_id(value: &str) -> Result<ScenarioId> {
1692    match value {
1693        "thin_reveal" => Ok(ScenarioId::ThinReveal),
1694        "fast_pan" => Ok(ScenarioId::FastPan),
1695        "diagonal_reveal" => Ok(ScenarioId::DiagonalReveal),
1696        "reveal_band" => Ok(ScenarioId::RevealBand),
1697        "motion_bias_band" => Ok(ScenarioId::MotionBiasBand),
1698        "layered_slats" => Ok(ScenarioId::LayeredSlats),
1699        "noisy_reprojection" => Ok(ScenarioId::NoisyReprojection),
1700        "heuristic_friendly_pan" => Ok(ScenarioId::HeuristicFriendlyPan),
1701        "contrast_pulse" => Ok(ScenarioId::ContrastPulse),
1702        "stability_holdout" => Ok(ScenarioId::StabilityHoldout),
1703        other => Err(Error::Message(format!("unknown scenario id {other}"))),
1704    }
1705}
1706
1707pub fn build_example_external_capture(
1708    config: &DemoConfig,
1709    output_dir: &Path,
1710) -> Result<ExternalImportArtifacts> {
1711    let manifest_path = output_dir.join("example_external_capture_manifest.json");
1712    write_example_manifest(&manifest_path)?;
1713    run_external_import_from_manifest(config, &manifest_path, output_dir)
1714}
1715
1716fn reproject_frame(previous_resolved: &ImageFrame, scene_frame: &SceneFrame) -> ImageFrame {
1717    let mut reprojected = ImageFrame::new(
1718        scene_frame.ground_truth.width(),
1719        scene_frame.ground_truth.height(),
1720    );
1721    for y in 0..scene_frame.ground_truth.height() {
1722        for x in 0..scene_frame.ground_truth.width() {
1723            let motion = scene_frame.motion[y * scene_frame.ground_truth.width() + x];
1724            reprojected.set(
1725                x,
1726                y,
1727                previous_resolved.sample_bilinear_clamped(
1728                    x as f32 + motion.to_prev_x,
1729                    y as f32 + motion.to_prev_y,
1730                ),
1731            );
1732        }
1733    }
1734    reprojected
1735}
1736
1737fn reproject_depth(previous_scene_frame: &SceneFrame, scene_frame: &SceneFrame) -> Vec<f32> {
1738    reproject_scalar_buffer(
1739        &previous_scene_frame.depth,
1740        scene_frame.ground_truth.width(),
1741        scene_frame.ground_truth.height(),
1742        &scene_frame.motion,
1743    )
1744}
1745
1746fn reproject_normals(previous_scene_frame: &SceneFrame, scene_frame: &SceneFrame) -> Vec<Normal3> {
1747    let width = scene_frame.ground_truth.width();
1748    let height = scene_frame.ground_truth.height();
1749    let mut reprojected = vec![Normal3::new(0.0, 0.0, 1.0); width * height];
1750    for y in 0..height {
1751        for x in 0..width {
1752            let index = y * width + x;
1753            let motion = scene_frame.motion[index];
1754            reprojected[index] = sample_normal_bilinear_clamped(
1755                &previous_scene_frame.normals,
1756                width,
1757                height,
1758                x as f32 + motion.to_prev_x,
1759                y as f32 + motion.to_prev_y,
1760            );
1761        }
1762    }
1763    reprojected
1764}
1765
1766fn reproject_scalar_buffer(
1767    previous_values: &[f32],
1768    width: usize,
1769    height: usize,
1770    motion: &[MotionVector],
1771) -> Vec<f32> {
1772    let mut reprojected = vec![0.0; width * height];
1773    for y in 0..height {
1774        for x in 0..width {
1775            let index = y * width + x;
1776            let vector = motion[index];
1777            reprojected[index] = sample_scalar_bilinear_clamped(
1778                previous_values,
1779                width,
1780                height,
1781                x as f32 + vector.to_prev_x,
1782                y as f32 + vector.to_prev_y,
1783            );
1784        }
1785    }
1786    reprojected
1787}
1788
1789fn sample_scalar_bilinear_clamped(
1790    values: &[f32],
1791    width: usize,
1792    height: usize,
1793    x: f32,
1794    y: f32,
1795) -> f32 {
1796    let x0 = x.floor();
1797    let y0 = y.floor();
1798    let x1 = x0 + 1.0;
1799    let y1 = y0 + 1.0;
1800    let tx = (x - x0).clamp(0.0, 1.0);
1801    let ty = (y - y0).clamp(0.0, 1.0);
1802    let sample = |sample_x: f32, sample_y: f32| {
1803        let sx = sample_x.clamp(0.0, width.saturating_sub(1) as f32) as usize;
1804        let sy = sample_y.clamp(0.0, height.saturating_sub(1) as f32) as usize;
1805        values[sy * width + sx]
1806    };
1807    let top = sample(x0, y0) * (1.0 - tx) + sample(x1, y0) * tx;
1808    let bottom = sample(x0, y1) * (1.0 - tx) + sample(x1, y1) * tx;
1809    top * (1.0 - ty) + bottom * ty
1810}
1811
1812fn sample_normal_bilinear_clamped(
1813    values: &[Normal3],
1814    width: usize,
1815    height: usize,
1816    x: f32,
1817    y: f32,
1818) -> Normal3 {
1819    let x0 = x.floor();
1820    let y0 = y.floor();
1821    let x1 = x0 + 1.0;
1822    let y1 = y0 + 1.0;
1823    let tx = (x - x0).clamp(0.0, 1.0);
1824    let ty = (y - y0).clamp(0.0, 1.0);
1825    let sample = |sample_x: f32, sample_y: f32| {
1826        let sx = sample_x.clamp(0.0, width.saturating_sub(1) as f32) as usize;
1827        let sy = sample_y.clamp(0.0, height.saturating_sub(1) as f32) as usize;
1828        values[sy * width + sx]
1829    };
1830    let mix = |a: Normal3, b: Normal3, t: f32| {
1831        Normal3::new(
1832            a.x + (b.x - a.x) * t,
1833            a.y + (b.y - a.y) * t,
1834            a.z + (b.z - a.z) * t,
1835        )
1836    };
1837    mix(
1838        mix(sample(x0, y0), sample(x1, y0), tx),
1839        mix(sample(x0, y1), sample(x1, y1), tx),
1840        ty,
1841    )
1842    .normalized()
1843}
1844
1845pub fn compute_external_compatible_mask(scene_frame: &SceneFrame) -> Vec<bool> {
1846    scene_frame
1847        .layers
1848        .iter()
1849        .zip(scene_frame.disocclusion_mask.iter().copied())
1850        .map(|(layer, disoccluded)| disoccluded && !matches!(*layer, SurfaceTag::ForegroundObject))
1851        .collect()
1852}