1use crate::error::PredictionRuleError;
4use crate::registry::profiles_v1;
5use animsmith_core::{
6 Applicability, Check, CheckCtx, CheckOutput, EngineFactIdV1, EngineFactStateV1,
7 EngineFactValueV1, EnginePredictionBasisV2, EnginePredictionFacetV3, EnginePredictionV3,
8 EvaluationScope, EvaluationScopeCode, ExactSourceTimingBasisReferenceV1,
9 ExactSourceTimingDomainV1, ExactSourceTimingKeyV1, ExactSourceTimingObservationStateV1,
10 Finding, LoadedSource, PredictionBasisReferenceV1, PredictionBasisReferenceV2,
11 PredictionFacetDemandV2, PredictionProvenanceV3, PredictionRuleAllocationV2,
12 PredictionUnavailableReasonV2, RawSourceBindingV2, RawSourceFieldIdV1, Severity,
13 SourceFormatV1, SourceSetCoverageStateV1,
14};
15
16pub const ENGINE_CLIP_BOUNDARY_CHECK_ID: &str = "engine-clip-boundary";
18
19const UNREAL_FAMILY: &str = "unreal";
20const UNREAL_PROFILE_REVISION: u32 = 1;
21const UNREAL_ENGINE_VERSION: &str = "5.8";
22const UNREAL_IMPORTER: &str = "fbx-importer";
23const UNREAL_BOUNDARY_SOURCE: &str = "unreal-animation-sequences-5.8";
24const FACET_LIMIT: usize = animsmith_core::PREDICTION_V2_MAX_CANDIDATE_FACETS_PER_RULE;
25
26pub struct EngineClipBoundaryCheck<'a> {
28 source: &'a LoadedSource,
29 provenance: Option<&'a PredictionProvenanceV3>,
30}
31
32impl<'a> EngineClipBoundaryCheck<'a> {
33 pub fn new(
35 source: &'a LoadedSource,
36 provenance: Option<&'a PredictionProvenanceV3>,
37 ) -> Result<Self, PredictionRuleError> {
38 if let Some(provenance) = provenance {
39 provenance
40 .validate()
41 .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
42 let raw_source = RawSourceBindingV2::from_source(
43 source.source_facts(),
44 source.exact_source_timing(),
45 )
46 .map_err(|_| PredictionRuleError::SourceProvenanceMismatch)?;
47 if &raw_source != provenance.raw_source()
48 || source.dependency_closure() != provenance.dependency_closure()
49 {
50 return Err(PredictionRuleError::SourceProvenanceMismatch);
51 }
52 if is_unreal_tuple(provenance) && !has_frozen_unreal_profile(provenance) {
53 return Err(PredictionRuleError::FrozenProfileMismatch);
54 }
55 }
56 Ok(Self { source, provenance })
57 }
58}
59
60impl Check for EngineClipBoundaryCheck<'_> {
61 fn id(&self) -> &'static str {
62 ENGINE_CLIP_BOUNDARY_CHECK_ID
63 }
64
65 fn applicability(&self, _ctx: &CheckCtx<'_>) -> Applicability {
66 match self.provenance {
67 Some(provenance)
68 if is_exact_unreal_fbx(self.source, provenance)
69 && !complete_empty_stack_inventory(self.source) =>
70 {
71 Applicability::Applicable
72 }
73 _ => Applicability::NotApplicable,
74 }
75 }
76
77 fn evaluate(&self, _ctx: &CheckCtx<'_>) -> CheckOutput {
78 let Some(provenance) = self.provenance else {
79 return empty_output();
80 };
81 if !is_exact_unreal_fbx(self.source, provenance)
82 || complete_empty_stack_inventory(self.source)
83 {
84 return empty_output();
85 }
86 let demand = facet_demand(self.source);
87 let (capacity, summary_required) = match demand {
88 PredictionFacetDemandV2::Exact(count) => (count, false),
89 PredictionFacetDemandV2::NPlusOne => (FACET_LIMIT.saturating_sub(1), true),
90 };
91 evaluate_allocated(self.source, provenance, capacity, summary_required)
92 }
93
94 fn prediction_facet_demand_v2(&self, _ctx: &CheckCtx<'_>) -> PredictionFacetDemandV2 {
95 match self.provenance {
96 Some(provenance)
97 if is_exact_unreal_fbx(self.source, provenance)
98 && !complete_empty_stack_inventory(self.source) =>
99 {
100 facet_demand(self.source)
101 }
102 _ => PredictionFacetDemandV2::Exact(0),
103 }
104 }
105
106 fn evaluate_with_prediction_allocation_v2(
107 &self,
108 _ctx: &CheckCtx<'_>,
109 allocation: PredictionRuleAllocationV2<'_>,
110 ) -> CheckOutput {
111 let Some(provenance) = self.provenance else {
112 return empty_output();
113 };
114 if !is_exact_unreal_fbx(self.source, provenance)
115 || complete_empty_stack_inventory(self.source)
116 {
117 return empty_output();
118 }
119 evaluate_allocated(
120 self.source,
121 provenance,
122 allocation.candidate_capacity(),
123 allocation.summary_required(),
124 )
125 }
126}
127
128fn facet_demand(source: &LoadedSource) -> PredictionFacetDemandV2 {
129 let rows = source.source_facts().clips().rows().len();
130 let inventory = usize::from(
131 source.source_facts().clips().coverage().state() != SourceSetCoverageStateV1::Complete,
132 );
133 match rows.checked_add(inventory) {
134 Some(count) if count <= FACET_LIMIT => PredictionFacetDemandV2::Exact(count),
135 _ => PredictionFacetDemandV2::NPlusOne,
136 }
137}
138
139fn evaluate_allocated(
140 source: &LoadedSource,
141 provenance: &PredictionProvenanceV3,
142 candidate_capacity: usize,
143 summary_required: bool,
144) -> CheckOutput {
145 let inventory_incomplete =
146 source.source_facts().clips().coverage().state() != SourceSetCoverageStateV1::Complete;
147 let inventory_slots = usize::from(inventory_incomplete && candidate_capacity != 0);
150 let row_capacity = candidate_capacity.saturating_sub(inventory_slots);
151 let mut findings = Vec::new();
152 let mut evaluated_scopes = Vec::with_capacity(row_capacity);
153 let mut facets =
154 Vec::with_capacity(candidate_capacity.saturating_add(usize::from(summary_required)));
155
156 let timing = source.exact_source_timing();
157 for source_clip_index in 0..source.source_facts().clips().rows().len().min(row_capacity) {
158 let scope = stack_scope(source_clip_index);
159 let row = timing.and_then(|timing| timing.clips().get(source_clip_index));
160 let declared_time_mode = timing.map(|timing| timing.declared_time_mode().state());
161 let frame_period = timing.map(|timing| timing.frame_period().state());
162 let tick_range = row.map(|row| row.source_time_range().state());
163 let basis = stack_basis(
164 provenance,
165 source_clip_index,
166 declared_time_mode,
167 frame_period,
168 tick_range,
169 );
170
171 match (declared_time_mode, frame_period, tick_range) {
172 (
173 Some(ExactSourceTimingObservationStateV1::Observed(_)),
174 Some(ExactSourceTimingObservationStateV1::Observed(period)),
175 Some(ExactSourceTimingObservationStateV1::Observed(range)),
176 ) => {
177 facets.push(
178 EnginePredictionFacetV3::available(scope.clone(), basis)
179 .expect("exact stack evidence forms an available facet"),
180 );
181 evaluated_scopes.push(scope.clone());
182 if range.end_units().rem_euclid(period.units_per_frame()) != 0 {
183 findings.push(
184 Finding::new(
185 ENGINE_CLIP_BOUNDARY_CHECK_ID,
186 Severity::Warning,
187 format!(
188 "FBX animation stack {source_clip_index} ends at KTime tick {}, which is not on the exact {}-tick frame lattice required by Unreal Engine 5.8",
189 range.end_units(),
190 period.units_per_frame()
191 ),
192 )
193 .prediction_scope(scope),
194 );
195 }
196 }
197 _ => facets.push(
198 EnginePredictionFacetV3::required_unavailable(
199 scope,
200 basis,
201 unavailable_reasons(declared_time_mode, frame_period, tick_range),
202 )
203 .expect("missing exact stack evidence forms required-unavailable"),
204 ),
205 }
206 }
207
208 if inventory_slots != 0 {
209 facets.push(
210 EnginePredictionFacetV3::required_unavailable(
211 inventory_scope(),
212 inventory_basis(provenance),
213 vec![PredictionUnavailableReasonV2::RawSourceIncomplete],
214 )
215 .expect("partial stack inventory forms required-unavailable"),
216 );
217 }
218
219 if summary_required {
220 facets.push(
221 EnginePredictionFacetV3::required_unavailable(
222 EvaluationScope::new(EvaluationScopeCode::custom(
223 "engine-clip-boundary:facet-budget",
224 )),
225 inventory_basis(provenance),
226 vec![PredictionUnavailableReasonV2::FacetBudgetExceeded],
227 )
228 .expect("facet-budget summary is valid"),
229 );
230 }
231
232 if facets.is_empty() {
233 return empty_output();
234 }
235 let prediction = EnginePredictionV3::new(provenance.identity().clone(), facets)
236 .expect("allocated clip-boundary facets satisfy the shared bound");
237 CheckOutput::from_coverage(findings, evaluated_scopes, Vec::new())
238 .with_engine_prediction_v3(prediction)
239}
240
241fn stack_basis<T, U, V>(
242 provenance: &PredictionProvenanceV3,
243 source_clip_index: usize,
244 declared_time_mode: Option<&ExactSourceTimingObservationStateV1<T>>,
245 frame_period: Option<&ExactSourceTimingObservationStateV1<U>>,
246 tick_range: Option<&ExactSourceTimingObservationStateV1<V>>,
247) -> EnginePredictionBasisV2 {
248 let mut references = common_basis();
249 let Some(binding) = provenance.raw_source().exact_source_timing() else {
250 return EnginePredictionBasisV2::new(references).expect("static basis is valid");
251 };
252 references.push(exact_reference(
253 ExactSourceTimingDomainV1::Document,
254 ExactSourceTimingKeyV1::Document,
255 "declared_time_mode.state",
256 binding,
257 ));
258 references.push(exact_reference(
259 ExactSourceTimingDomainV1::Document,
260 ExactSourceTimingKeyV1::Document,
261 "frame_period.state",
262 binding,
263 ));
264 references.push(exact_reference(
265 ExactSourceTimingDomainV1::Clip,
266 ExactSourceTimingKeyV1::Clip {
267 source_clip_index: source_clip_index as u64,
268 },
269 "source_time_range.state",
270 binding,
271 ));
272 if matches!(
273 declared_time_mode,
274 Some(ExactSourceTimingObservationStateV1::Observed(_))
275 ) {
276 references.push(exact_reference(
277 ExactSourceTimingDomainV1::Document,
278 ExactSourceTimingKeyV1::Document,
279 "declared_time_mode.value.time_mode",
280 binding,
281 ));
282 }
283 if matches!(
284 frame_period,
285 Some(ExactSourceTimingObservationStateV1::Observed(_))
286 ) {
287 references.push(exact_reference(
288 ExactSourceTimingDomainV1::Document,
289 ExactSourceTimingKeyV1::Document,
290 "frame_period.value.units_per_frame",
291 binding,
292 ));
293 }
294 if matches!(
295 tick_range,
296 Some(ExactSourceTimingObservationStateV1::Observed(_))
297 ) {
298 references.push(exact_reference(
299 ExactSourceTimingDomainV1::Clip,
300 ExactSourceTimingKeyV1::Clip {
301 source_clip_index: source_clip_index as u64,
302 },
303 "source_time_range.value.end_units",
304 binding,
305 ));
306 }
307 EnginePredictionBasisV2::new(references).expect("exact timing basis is valid")
308}
309
310fn inventory_basis(provenance: &PredictionProvenanceV3) -> EnginePredictionBasisV2 {
311 let mut references = common_basis();
312 if let Some(binding) = provenance.raw_source().exact_source_timing() {
313 references.push(exact_reference(
314 ExactSourceTimingDomainV1::Document,
315 ExactSourceTimingKeyV1::Document,
316 "clip_coverage.state",
317 binding,
318 ));
319 references.push(exact_reference(
320 ExactSourceTimingDomainV1::Document,
321 ExactSourceTimingKeyV1::Document,
322 "clip_coverage.reason",
323 binding,
324 ));
325 }
326 EnginePredictionBasisV2::new(references).expect("inventory basis is valid")
327}
328
329fn common_basis() -> Vec<PredictionBasisReferenceV2> {
330 vec![
331 PredictionBasisReferenceV2::v1(
332 PredictionBasisReferenceV1::profile_fact("whole_end_frame_required")
333 .expect("static fact id is valid"),
334 ),
335 PredictionBasisReferenceV2::v1(
336 PredictionBasisReferenceV1::primary_source(UNREAL_BOUNDARY_SOURCE)
337 .expect("static source id is valid"),
338 ),
339 ]
340}
341
342fn exact_reference(
343 domain: ExactSourceTimingDomainV1,
344 key: ExactSourceTimingKeyV1,
345 field: &'static str,
346 binding: &animsmith_core::ExactSourceTimingBindingV1,
347) -> PredictionBasisReferenceV2 {
348 PredictionBasisReferenceV2::exact_source_timing(
349 ExactSourceTimingBasisReferenceV1::from_binding(
350 domain,
351 key,
352 RawSourceFieldIdV1::new(field).expect("static exact field id is valid"),
353 binding,
354 )
355 .expect("same-load exact field resolves"),
356 )
357}
358
359fn unavailable_reasons<T, U, V>(
360 declared_time_mode: Option<&ExactSourceTimingObservationStateV1<T>>,
361 frame_period: Option<&ExactSourceTimingObservationStateV1<U>>,
362 tick_range: Option<&ExactSourceTimingObservationStateV1<V>>,
363) -> Vec<PredictionUnavailableReasonV2> {
364 if declared_time_mode.is_none() && frame_period.is_none() && tick_range.is_none() {
365 return vec![custom_reason("animsmith:exact_source_timing_unavailable")];
366 }
367 let mut reasons = Vec::new();
368 if !matches!(
369 declared_time_mode,
370 Some(ExactSourceTimingObservationStateV1::Observed(_))
371 ) {
372 reasons.push(custom_reason(
373 "animsmith:source_declared_time_mode_unavailable",
374 ));
375 }
376 if !matches!(
377 frame_period,
378 Some(ExactSourceTimingObservationStateV1::Observed(_))
379 ) {
380 reasons.push(custom_reason("animsmith:source_frame_period_unavailable"));
381 }
382 if !matches!(
383 tick_range,
384 Some(ExactSourceTimingObservationStateV1::Observed(_))
385 ) {
386 reasons.push(custom_reason(
387 "animsmith:source_clip_time_range_unavailable",
388 ));
389 }
390 reasons
391}
392
393fn custom_reason(code: &'static str) -> PredictionUnavailableReasonV2 {
394 PredictionUnavailableReasonV2::custom(code).expect("static reason code is valid")
395}
396
397fn stack_scope(source_clip_index: usize) -> EvaluationScope {
398 EvaluationScope::new(EvaluationScopeCode::ENGINE_CLIP_BOUNDARY)
399 .subject(format!("source_stack:{source_clip_index}"))
400}
401
402fn inventory_scope() -> EvaluationScope {
403 EvaluationScope::new(EvaluationScopeCode::ENGINE_CLIP_BOUNDARY_INVENTORY)
404}
405
406fn empty_output() -> CheckOutput {
407 CheckOutput::from_coverage(Vec::new(), Vec::new(), Vec::new())
408}
409
410fn complete_empty_stack_inventory(source: &LoadedSource) -> bool {
411 let clips = source.source_facts().clips();
412 clips.coverage().state() == SourceSetCoverageStateV1::Complete && clips.rows().is_empty()
413}
414
415fn is_unreal_tuple(provenance: &PredictionProvenanceV3) -> bool {
416 let selection = provenance.profile().selection();
417 selection.family() == UNREAL_FAMILY
418 && selection.profile_revision() == UNREAL_PROFILE_REVISION
419 && selection.engine_version() == UNREAL_ENGINE_VERSION
420 && selection.importer() == UNREAL_IMPORTER
421}
422
423fn has_frozen_unreal_profile(provenance: &PredictionProvenanceV3) -> bool {
424 profiles_v1().iter().any(|profile| {
425 let selection = profile.selection();
426 selection.family() == UNREAL_FAMILY
427 && selection.profile_revision() == UNREAL_PROFILE_REVISION
428 && selection.engine_version() == UNREAL_ENGINE_VERSION
429 && selection.importer() == UNREAL_IMPORTER
430 && profile.facts_identity() == provenance.profile().facts_identity()
431 })
432}
433
434fn is_exact_unreal_fbx(source: &LoadedSource, provenance: &PredictionProvenanceV3) -> bool {
435 source.source_facts().format() == SourceFormatV1::Fbx
436 && provenance.source_format() == SourceFormatV1::Fbx
437 && is_unreal_tuple(provenance)
438 && has_frozen_unreal_profile(provenance)
439 && matches!(
440 provenance
441 .profile()
442 .fact(EngineFactIdV1::WholeEndFrameRequired)
443 .map(|fact| fact.state()),
444 Some(EngineFactStateV1::Known(EngineFactValueV1::Boolean(true)))
445 )
446 && provenance
447 .profile()
448 .source(UNREAL_BOUNDARY_SOURCE)
449 .is_some()
450}