Skip to main content

assetpack_core/file_transform/
selector.rs

1use std::{
2  io::{self, BufReader, Cursor, Write},
3  sync::Arc,
4};
5
6use sha3::{Digest, Sha3_256};
7
8use super::{FileHint, FileTransform, FileTransformConfig, TransformSelection, TransformSpec, spool::StoredStreamSpool};
9use crate::{
10  FastCdcSplitter,
11  error::Result,
12  hash::Hash32,
13  pipeline::{ChunkCompressionPolicy, ChunkCompressor, DefaultChunkCompressor, Pipeline, PipelineConfig},
14};
15
16struct HashingSink {
17  hasher: Sha3_256,
18  bytes: u64,
19}
20
21impl HashingSink {
22  fn new() -> Self {
23    Self {
24      hasher: Sha3_256::new(),
25      bytes: 0,
26    }
27  }
28
29  fn finish(self) -> (Hash32, u64) {
30    let digest = self.hasher.finalize();
31    (Hash32::new(digest.into()), self.bytes)
32  }
33}
34
35impl Write for HashingSink {
36  fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
37    self.hasher.update(buf);
38    self.bytes += buf.len() as u64;
39    Ok(buf.len())
40  }
41
42  fn flush(&mut self) -> io::Result<()> {
43    Ok(())
44  }
45}
46
47#[derive(Clone)]
48pub struct TransformSelector {
49  config: FileTransformConfig,
50  temp_dir: Option<std::path::PathBuf>,
51  candidates: Vec<TransformCandidate>,
52}
53
54impl TransformSelector {
55  pub fn new(config: FileTransformConfig, temp_dir: Option<std::path::PathBuf>, candidates: Vec<TransformSpec>) -> Self {
56    let candidates = candidates
57      .into_iter()
58      .filter(|spec| spec.selectable && (spec.enabled)(&config))
59      .map(|spec| TransformCandidate {
60        transform: (spec.build)(&config),
61        use_quick_check: spec.use_quick_check,
62      })
63      .collect();
64
65    Self {
66      config,
67      temp_dir,
68      candidates,
69    }
70  }
71
72  pub fn select_bytes(&self, data: Vec<u8>, hint: &FileHint, original_hash: Hash32) -> Result<TransformSelection> {
73    let original_size = data.len() as u64;
74    if !self.config.enabled {
75      return Ok(TransformSelection::none(data, original_hash));
76    }
77    if original_size > self.config.max_transform_bytes {
78      return Ok(TransformSelection::none(data, original_hash));
79    }
80    if self.candidates.is_empty() {
81      return Ok(TransformSelection::none(data, original_hash));
82    }
83    let pipeline = Pipeline::new(PipelineConfig {
84      splitter: FastCdcSplitter::v1_defaults(),
85      chunk_compression: ChunkCompressionPolicy::Auto,
86      discard_payload: true,
87    });
88    let compressor = DefaultChunkCompressor;
89    let extension = hint.extension.as_deref();
90    let mut candidate_plans = Vec::new();
91
92    for candidate in &self.candidates {
93      let attempt = TransformAttempt {
94        transform: candidate.transform.as_ref(),
95        data: &data,
96        hint,
97        original_hash,
98        original_size,
99        extension,
100        use_quick_check: candidate.use_quick_check,
101        pipeline: &pipeline,
102        compressor: &compressor,
103      };
104      if let Some(result) = self.try_transform(attempt)? {
105        candidate_plans.push(result);
106      }
107    }
108
109    let baseline_selection = TransformSelection::none(data, original_hash);
110    let baseline_plan = match pipeline.run_with_selection_ref(&baseline_selection, extension, &compressor) {
111      Ok(plan) => plan,
112      Err(_) => {
113        return Ok(baseline_selection);
114      }
115    };
116    let threshold = (baseline_plan.estimated_bytes as f64 * (1.0 - self.config.min_gain)).floor();
117
118    let _plans = candidate_plans
119      .iter()
120      .map(|(sel, size)| (sel.transform_id, *size))
121      .collect::<Vec<_>>();
122    let mut best: Option<(TransformSelection, u64)> = None;
123    for candidate in candidate_plans {
124      if (candidate.1 as f64) <= threshold {
125        best = pick_best(best, candidate);
126      }
127    }
128
129    // println!(
130    //   "Transform selection plans: baseline={} bytes, candidates={:?}, best={:?}",
131    //   baseline_plan.estimated_bytes,
132    //   plans,
133    //   best.as_ref().map(|(sel, size)| (sel.transform_id, size))
134    // );
135    if let Some((selection, _)) = best {
136      return Ok(selection);
137    }
138
139    Ok(baseline_selection)
140  }
141
142  fn try_transform(&self, attempt: TransformAttempt<'_>) -> Result<Option<(TransformSelection, u64)>> {
143    let TransformAttempt {
144      transform,
145      data,
146      hint,
147      original_hash,
148      original_size,
149      extension,
150      use_quick_check,
151      pipeline,
152      compressor,
153    } = attempt;
154
155    if use_quick_check && !transform.quick_check(hint) {
156      return Ok(None);
157    }
158
159    let mut spool = if original_size <= self.config.max_in_memory_bytes {
160      StoredStreamSpool::new_memory(self.config.eval.max_buffer_bytes)
161    } else if self.config.spill_to_disk {
162      match StoredStreamSpool::new_file(self.temp_dir.as_deref()) {
163        Ok(spool) => spool,
164        Err(_) => {
165          return Ok(None);
166        }
167      }
168    } else {
169      return Ok(None);
170    };
171
172    if transform.encode(&mut BufReader::new(Cursor::new(data)), &mut spool).is_err() {
173      return Ok(None);
174    }
175
176    let mut verify_reader = match spool.reader() {
177      Ok(reader) => reader,
178      Err(_) => {
179        return Ok(None);
180      }
181    };
182    let mut verify_reader = BufReader::new(&mut verify_reader);
183    let mut sink = HashingSink::new();
184    if transform.decode(&mut verify_reader, &mut sink).is_err() {
185      return Ok(None);
186    }
187
188    let (decoded_hash, decoded_size) = sink.finish();
189    if decoded_hash != original_hash || decoded_size != original_size {
190      return Ok(None);
191    }
192
193    let stored_stream = match spool.into_bytes() {
194      Ok(bytes) => bytes,
195      Err(_) => {
196        return Ok(None);
197      }
198    };
199
200    let selection = TransformSelection {
201      transform_id: transform.id(),
202      transform_version: transform.version(),
203      original_size,
204      original_hash,
205      stored_stream,
206    };
207    let plan = match pipeline.run_with_selection_ref(&selection, extension, compressor) {
208      Ok(plan) => plan,
209      Err(_) => {
210        return Ok(None);
211      }
212    };
213
214    Ok(Some((selection, plan.estimated_bytes)))
215  }
216}
217
218impl std::fmt::Debug for TransformSelector {
219  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
220    f.debug_struct("TransformSelector")
221      .field("config", &self.config)
222      .field("temp_dir", &self.temp_dir)
223      .field("candidate_count", &self.candidates.len())
224      .finish()
225  }
226}
227
228#[derive(Clone)]
229struct TransformCandidate {
230  transform: Arc<dyn FileTransform>,
231  use_quick_check: bool,
232}
233
234struct TransformAttempt<'a> {
235  transform: &'a dyn FileTransform,
236  data: &'a [u8],
237  hint: &'a FileHint,
238  original_hash: Hash32,
239  original_size: u64,
240  extension: Option<&'a str>,
241  use_quick_check: bool,
242  pipeline: &'a Pipeline,
243  compressor: &'a dyn ChunkCompressor,
244}
245
246fn pick_best(best: Option<(TransformSelection, u64)>, candidate: (TransformSelection, u64)) -> Option<(TransformSelection, u64)> {
247  match best {
248    None => Some(candidate),
249    Some((best_sel, best_est)) => {
250      if candidate.1 < best_est {
251        Some(candidate)
252      } else {
253        Some((best_sel, best_est))
254      }
255    }
256  }
257}