Browse / github.com/storytold/artcraft / patch
Validate native reference limits before image and video generation
Validate native reference limits before image and video generation Native image/video submission could retain references from a more permissive model or accept library clips whose combined duration exceeded the current model's limit. Validate the complete active reference input immediately before generation: counts, supported kinds, upload/token readiness, aggregate durations, and first/last-frame support. Invalid input stays attached and an actionable error explains what to remove or reattach. References with unknown/nonfinite duration must be reattached from the library when a duration limit applies; they cannot silently bypass that limit. Addresses the r
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- channelramble (imported from pull request #2375, open)
- Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373- Size
- +101 −2 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
940ffaa54a520282ae27609d
How it went
No other operator has reported using it yet.
Diff
raw · artcraft-native/src/pages/create_image.rs artcraft-native/src/pages/create_video.rs artcraft-native/src/prompt_box/refs.rs netlify.toml
@@ -18,7 +18,7 @@ use crate::pages::common::{self, AppRequest, CostState, Env}; use crate::pages::toolbar; use crate::prompt_box::deck::{DeckAction, DeckLimits}; use crate::prompt_box::model_selector::model_selector;-use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, References};+use crate::prompt_box::refs::{validate_reference_list, ImageSlot, RefKind, RefMedia, References}; use crate::prompt_box::{self, DeckMode, PromptBoxAction, PromptBoxProps, PromptBoxState, Slot, editor}; const PLACEHOLDER: &str = "Describe what you want in the image...";@@ -227,6 +227,10 @@ impl CreateImagePage { env.toasts.error("Please wait for your references to finish uploading"); return; }+ if let Err(message) = validate_reference_list(&self.refs.images, RefKind::Image, model.image_refs_max, None) {+ env.toasts.error(message);+ return;+ } // Other providers take the references as links (when the model takes references there). let image_urls = if provider == Provider::Artcraft || model.image_refs_max == 0 { Vec::new()@@ -20,7 +20,7 @@ use crate::prompt_box::deck::{DeckAction, DeckLimits}; use crate::prompt_box::editor::{self, MentionItem, MentionKind}; use crate::prompt_box::model_selector::model_selector; use crate::prompt_box::pickers::{self, PickOption, Trigger};-use crate::prompt_box::refs::{ImageSlot, RefKind, RefMedia, References};+use crate::prompt_box::refs::{validate_reference_list, ImageSlot, RefKind, RefMedia, References}; use crate::prompt_box::{self, DeckMode, PromptBoxAction, PromptBoxProps, PromptBoxState, Slot}; use crate::ui::icons::Icon; use crate::ui::widgets::Leading;@@ -315,6 +315,10 @@ impl CreateVideoPage { env.toasts.error("Please wait for your references to finish uploading"); return; }+ if let Err(message) = self.validate_references(model) {+ env.toasts.error(message);+ return;+ } self.generating = true; let ref_image = self.refs.first_frame.as_ref().or(self.refs.images.first()).and_then(|r| r.preview.clone()); let meta = EnqueueMeta { prompt: self.settings.prompt.clone(), model_id: model.id.clone(), batch_count: u32::from(model.valid_batch(self.settings.count)), ref_image, provider: Provider::Artcraft };@@ -328,6 +332,20 @@ impl CreateVideoPage { env.backend.generate(Modality::Video, fields, meta); } + fn validate_references(&self, model: &ModelInfo) -> Result<(), String> {+ match self.settings.input_mode {+ InputMode::Reference => {+ validate_reference_list(&self.refs.images, RefKind::Image, model.image_refs_max, None)?;+ validate_reference_list(&self.refs.videos, RefKind::Video, model.video_refs_max, model.video_refs_max_secs)?;+ validate_reference_list(&self.refs.audios, RefKind::Audio, model.audio_refs_max, model.audio_refs_max_secs)+ },+ InputMode::Keyframe => {+ validate_reference_list(self.refs.first_frame.as_slice(), RefKind::Image, usize::from(model.start_frame), None)?;+ validate_reference_list(self.refs.last_frame.as_slice(), RefKind::Image, usize::from(model.end_frame), None)+ },+ }+ }+ /// The characters dialog picked `name`: mention it at the end of the prompt. pub fn mention_character(&mut self, name: &str, token: &str) { let p = &mut self.settings.prompt;@@ -617,6 +635,55 @@ fn frames_full_message(model: Option<&ModelInfo>) -> &'static str { } } +#[cfg(test)]+mod reference_validation_tests {+ use super::*;++ #[test]+ fn switching_to_a_stricter_model_rejects_retained_references() {+ let mut page = CreateVideoPage::default();+ page.refs.images = (0..3).map(|i| RefMedia::from_library(RefKind::Image, format!("mf_{i}"), None, None, 0.0)).collect();+ let mut model = ModelInfo { image_refs_max: 3, ..Default::default() };+ assert!(page.validate_references(&model).is_ok());+ model.image_refs_max = 1;+ assert!(page.validate_references(&model).unwrap_err().contains("at most 1"));+ model.image_refs_max = 0;+ assert!(page.validate_references(&model).unwrap_err().contains("does not support"));+ assert_eq!(page.refs.images.len(), 3, "validation must not silently discard user inputs");+ }++ #[test]+ fn library_duration_limits_apply_to_aggregate_inputs_and_unknown_durations() {+ let mut page = CreateVideoPage::default();+ let model = ModelInfo { video_refs_max: 2, video_refs_max_secs: Some(10), ..Default::default() };+ page.refs.videos = vec![reference(6.0), reference(6.0)];+ assert!(page.validate_references(&model).unwrap_err().contains("cannot exceed 10s"));+ page.refs.videos[1].duration_secs = 4.0;+ assert!(page.validate_references(&model).is_ok());+ for unknown in [0.0, f32::NAN, f32::INFINITY, -1.0] {+ page.refs.videos[1].duration_secs = unknown;+ assert!(page.validate_references(&model).unwrap_err().contains("no known duration"));+ }+ }++ #[test]+ fn unready_or_unsupported_keyframes_are_rejected() {+ let mut page = CreateVideoPage::default();+ page.settings.input_mode = InputMode::Keyframe;+ page.refs.first_frame = Some(RefMedia::uploading(RefKind::Image, None));+ let model = ModelInfo { start_frame: true, ..Default::default() };+ assert!(page.validate_references(&model).is_err());+ page.refs.first_frame = Some(RefMedia::from_library(RefKind::Image, "mf_first".into(), None, None, 0.0));+ assert!(page.validate_references(&model).is_ok());+ page.refs.last_frame = page.refs.first_frame.clone();+ assert!(page.validate_references(&model).is_err());+ }++ fn reference(duration_secs: f32) -> RefMedia {+ RefMedia::from_library(RefKind::Video, "mf_video".into(), None, None, duration_secs)+ }+}+ #[cfg(test)] mod tests { use super::*;@@ -141,6 +141,27 @@ impl References { } } +/// Validate the references that will actually be submitted against the active+/// model, including library picks and references retained after a model change.+pub fn validate_reference_list(items: &[RefMedia], kind: RefKind, maximum: usize, maybe_max_secs: Option<u16>) -> Result<(), String> {+ if items.len() > maximum {+ return Err(if maximum == 0 { format!("This model does not support {} references. Remove them before generating.", kind.noun()) } else { format!("This model accepts at most {maximum} {} references. Remove the extras before generating.", kind.noun()) });+ }+ if items.iter().any(|item| item.kind != kind || item.status != RefStatus::Ready || item.token.as_deref().is_none_or(|token| token.trim().is_empty())) {+ return Err("A reference is not ready. Please finish uploading or attach it again.".into());+ }+ if let Some(max) = maybe_max_secs {+ if items.iter().any(|item| !item.duration_secs.is_finite() || item.duration_secs <= 0.0) {+ return Err(format!("A {} reference has no known duration. Please attach it again from your library.", kind.noun()));+ }+ let total: f32 = items.iter().map(|item| item.duration_secs).sum();+ if !total.is_finite() || total > f32::from(max) + 0.5 {+ return Err(format!("Total {} reference duration cannot exceed {max}s", kind.noun()));+ }+ }+ Ok(())+}+ #[cfg(test)] mod tests { use super::*;new file mode 100644@@ -0,0 +1,7 @@+# The `storyteller-docs` Netlify site still builds this repository, but its+# build command (./script/netlify_build_docs.sh) and the docs themselves now+# live in storytold/artcraft-services, so every deploy preview here fails.+# Skip those builds until the Netlify site is repointed or disconnected, then+# delete this file.+[build]+ ignore = "exit 0"