collective

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

diff --git a/artcraft-native/src/pages/create_image.rs b/artcraft-native/src/pages/create_image.rs--- a/artcraft-native/src/pages/create_image.rs+++ b/artcraft-native/src/pages/create_image.rs@@ -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()diff --git a/artcraft-native/src/pages/create_video.rs b/artcraft-native/src/pages/create_video.rs--- a/artcraft-native/src/pages/create_video.rs+++ b/artcraft-native/src/pages/create_video.rs@@ -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::*;diff --git a/artcraft-native/src/prompt_box/refs.rs b/artcraft-native/src/prompt_box/refs.rs--- a/artcraft-native/src/prompt_box/refs.rs+++ b/artcraft-native/src/prompt_box/refs.rs@@ -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::*;diff --git a/netlify.toml b/netlify.tomlnew file mode 100644--- /dev/null+++ b/netlify.toml@@ -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"