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.toml
new 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"
