collective

Browse / github.com/storytold/artcraft / patch

fix(canvas): route Flux inpainting and surface submission failures

Canvas: Flux Pro (Inpainting) fails silently — inpaint model mapped to FluxPro11, omni endpoint returns 500 ## Summary In the Canvas (Image Editor), generating with **Flux Pro (Inpainting)** and a painted mask does nothing visible: no error toast, no result, no task. The desktop log shows the omni endpoint returning **HTTP 500**. The likely cause is a client-side model mapping: the inpainting model `FluxPro1` is sent to the omni endpoint as `FluxPro11` (Flux Pro 1.1, a text-to-image model), together with an inpainting mask. ## Steps to reproduce 1. Open the Canvas (pen tool icon), pick an existing image. 2. Select the model **Flux Pro (Inpainting)**. 3. Select the **Mask** tool and paint over an

Status
open
Score
0.00
Adopters
0 other operators
Author
channelramble (imported from pull request #2363, open)
Base
d4e4ac69bbd93d237eaf5d0260bc6a5ff3a5e373
Size
+259 −16 in 9 files
Review
pending
Shared
2026-10-28
Id
9bffc0150a1935a1ab8c56d4

How it went

No other operator has reported using it yet.

Diff

raw · crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_enums_model.rs crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft.rs crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft_via_legacy.rs crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_pro_1_inpaint.rs crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/mod.rs frontend/libs/components/pagedraw/src/lib/PageDraw.tsx frontend/libs/components/pagedraw/src/lib/hooks/useGenerationSubmission.spec.ts frontend/libs/components/pagedraw/src/lib/hooks/useGenerationSubmission.ts netlify.toml

diff --git a/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_enums_model.rs b/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_enums_model.rs--- a/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_enums_model.rs+++ b/crates/desktop/artcraft/src/core/api_adapters/models/image/tauri_image_model_to_enums_model.rs@@ -8,7 +8,7 @@ pub fn tauri_image_model_to_enums_model(model: TauriImageModel) -> Option<Common   match model {     TauriImageModel::Flux1Dev => Some(CommonImageModel::Flux1Dev),     TauriImageModel::Flux1Schnell => Some(CommonImageModel::Flux1Schnell),-    TauriImageModel::FluxPro1 => Some(CommonImageModel::FluxPro11), // TODO: Might be a slight mismatch+    TauriImageModel::FluxPro1 => None, // Dedicated inpaint endpoint; Flux Pro 1.1 cannot inpaint.     TauriImageModel::FluxPro11 => Some(CommonImageModel::FluxPro11),     TauriImageModel::FluxPro11Ultra => Some(CommonImageModel::FluxPro11Ultra),     TauriImageModel::GptImage1 => Some(CommonImageModel::GptImage1),@@ -36,3 +36,14 @@ pub fn tauri_image_model_to_enums_model(model: TauriImageModel) -> Option<Common     TauriImageModel::FluxDevJuggernaut => None,   } }++#[cfg(test)]+mod tests {+  use super::*;++  #[test]+  fn flux_inpaint_must_not_be_sent_as_flux_pro_11() {+    assert!(tauri_image_model_to_enums_model(TauriImageModel::FluxPro1).is_none());+    assert!(matches!(tauri_image_model_to_enums_model(TauriImageModel::FluxPro11), Some(CommonImageModel::FluxPro11)));+  }+}diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft.rs--- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft.rs+++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft.rs@@ -59,5 +59,18 @@ fn is_legacy_only_model(model: TauriImageModel) -> bool {       | TauriImageModel::Midjourney       | TauriImageModel::FluxProKontextMax       | TauriImageModel::FluxDevJuggernaut+      | TauriImageModel::FluxPro1   ) }++#[cfg(test)]+mod tests {+  use super::*;++  #[test]+  fn flux_inpaint_uses_legacy_but_text_to_image_uses_omni() {+    assert!(is_legacy_only_model(TauriImageModel::FluxPro1));+    assert!(!is_legacy_only_model(TauriImageModel::FluxPro11));+    assert!(!is_legacy_only_model(TauriImageModel::FluxPro11Ultra));+  }+}diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft_via_legacy.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft_via_legacy.rs--- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft_via_legacy.rs+++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/handle_artcraft_via_legacy.rs@@ -1,3 +1,4 @@+use super::legacy::artcraft_flux_pro_1_inpaint::handle_flux_pro_1_inpaint; use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;  use crate::core::commands::enqueue::generate_error::GenerateError;@@ -23,6 +24,9 @@ pub async fn handle_artcraft_via_legacy(   let model = request.model.ok_or(GenerateError::no_model_specified())?;    match model {+    TauriImageModel::FluxPro1 => {+      handle_flux_pro_1_inpaint(request, semantic_media_files, creds, app_env_configs).await+    }     TauriImageModel::FluxDevJuggernaut => {       handle_flux_dev_juggernaut_inpaint(request, semantic_media_files, creds, app_env_configs).await     }diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_pro_1_inpaint.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_pro_1_inpaint.rsnew file mode 100644--- /dev/null+++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/artcraft_flux_pro_1_inpaint.rs@@ -0,0 +1,124 @@+use log::{error, info};++use artcraft_api_defs::generate::image::inpaint::flux_pro_1_inpaint_image::{FluxPro1InpaintImageNumImages, FluxPro1InpaintImageRequest};+use artcraft_client::credentials::storyteller_credential_set::StorytellerCredentialSet;+use artcraft_client::endpoints::generate::image::inpaint::flux_pro_1_inpaint_image::flux_pro_1_inpaint_image;+use enums::common::generation_provider::GenerationProvider;+use enums::tauri::tasks::task_type::TaskType;+use uuid_utils::uuid::generate_random_uuid;++use tokens::tokens::media_files::MediaFileToken;++use crate::core::commands::enqueue::generate_error::{BadInputReason, GenerateError};+use crate::core::commands::enqueue::task_enqueue_success::TaskEnqueueSuccess;+use crate::core::commands::generate::generate_image::tauri_generate_image_request::TauriGenerateImageRequest;+use crate::core::commands::generate::generate_image::utils::parse_semantic_media_files::SemanticMediaFiles;+use crate::core::events::generation_events::common::GenerationModel;+use crate::core::state::app_env_configs::app_env_configs::AppEnvConfigs;++/// Handle FluxPro1 inpainting via the legacy dedicated endpoint.+///+/// Requires a source image (from scene, canvas, or image_media_tokens) and an inpainting mask.+pub async fn handle_flux_pro_1_inpaint(request: &TauriGenerateImageRequest, semantic_media_files: &SemanticMediaFiles, creds: &StorytellerCredentialSet, app_env_configs: &AppEnvConfigs) -> Result<TaskEnqueueSuccess, GenerateError> {+  let inpaint_request = build_inpaint_request(request, semantic_media_files)?;++  info!("Calling FluxPro1 inpaint...");++  let response = flux_pro_1_inpaint_image(&app_env_configs.storyteller_host, Some(creds), inpaint_request).await.map_err(|err| {+    error!("FluxPro1 inpaint failed: {:?}", err);+    GenerateError::from(err)+  })?;++  info!("FluxPro1 inpaint succeeded: job_token={}", response.inference_job_token);++  Ok(TaskEnqueueSuccess { task_type: TaskType::ImageInpaintEdit, model: Some(GenerationModel::FluxPro1), provider: GenerationProvider::Artcraft, provider_job_id: Some(response.inference_job_token.to_string()), maybe_queue_status_url: None, maybe_prompt_token: None, maybe_queue_response_url: None })+}++fn build_inpaint_request(request: &TauriGenerateImageRequest, semantic_media_files: &SemanticMediaFiles) -> Result<FluxPro1InpaintImageRequest, GenerateError> {+  let image_media_token = get_first_image_token(request, semantic_media_files).ok_or(GenerateError::required_source_image_not_provided())?;++  let mask_media_token = semantic_media_files.inpainting_mask_image_media_token.clone().ok_or(GenerateError::required_source_image_mask_not_provided())?;++  let num_images = match request.batch_size {+    None => None,+    Some(1) => Some(FluxPro1InpaintImageNumImages::One),+    Some(2) => Some(FluxPro1InpaintImageNumImages::Two),+    Some(3) => Some(FluxPro1InpaintImageNumImages::Three),+    Some(4) => Some(FluxPro1InpaintImageNumImages::Four),+    Some(other) => {+      return Err(GenerateError::BadInput(BadInputReason::InvalidNumberOfRequestedImages { min: 1, max: 4, requested: other }));+    },+  };++  let uuid_idempotency_token = generate_random_uuid();++  Ok(FluxPro1InpaintImageRequest { uuid_idempotency_token, prompt: request.prompt.clone(), image_media_token, mask_media_token, num_images })+}++fn get_first_image_token(request: &TauriGenerateImageRequest, semantic_media_files: &SemanticMediaFiles) -> Option<MediaFileToken> {+  if let Some(scene_token) = &semantic_media_files.scene_image_media_token {+    return Some(scene_token.clone());+  }+  if let Some(canvas_token) = &semantic_media_files.canvas_image_media_token {+    return Some(canvas_token.clone());+  }+  if let Some(media_tokens) = &request.image_media_tokens {+    if let Some(token) = media_tokens.first() {+      return Some(token.clone());+    }+  }+  None+}++#[cfg(test)]+mod tests {+  use super::*;+  use serde_json::json;++  #[test]+  fn builds_dedicated_inpaint_payload_with_source_mask_prompt_and_count() {+    let request = request(json!({"image_media_tokens": ["mf_source"], "prompt": "a vase", "batch_size": 3}));+    let built = build_inpaint_request(&request, &media(None, None, Some("mf_mask"))).unwrap();+    assert_eq!(built.image_media_token.as_str(), "mf_source");+    assert_eq!(built.mask_media_token.as_str(), "mf_mask");+    assert_eq!(built.prompt.as_deref(), Some("a vase"));+    assert!(matches!(built.num_images, Some(FluxPro1InpaintImageNumImages::Three)));+    assert!(!built.uuid_idempotency_token.is_empty());+  }++  #[test]+  fn semantic_sources_take_precedence_over_references() {+    let request = request(json!({"image_media_tokens": ["mf_reference"]}));+    for (scene, canvas, expected) in [(Some("mf_scene"), Some("mf_canvas"), "mf_scene"), (None, Some("mf_canvas"), "mf_canvas")] {+      let built = build_inpaint_request(&request, &media(scene, canvas, Some("mf_mask"))).unwrap();+      assert_eq!(built.image_media_token.as_str(), expected);+    }+  }++  #[test]+  fn rejects_missing_source_and_mask_before_submitting() {+    assert!(build_inpaint_request(&request(json!({})), &media(None, None, Some("mf_mask"))).is_err());+    assert!(build_inpaint_request(&request(json!({"image_media_tokens": []})), &media(None, None, Some("mf_mask"))).is_err());+    assert!(build_inpaint_request(&request(json!({"image_media_tokens": ["mf_source"]})), &media(None, None, None)).is_err());+  }++  #[test]+  fn validates_batch_bounds_and_keeps_server_default() {+    let files = media(None, Some("mf_source"), Some("mf_mask"));+    for count in [0, 5, u32::MAX] {+      assert!(build_inpaint_request(&request(json!({"batch_size": count})), &files).is_err());+    }+    for count in 1..=4 {+      assert!(build_inpaint_request(&request(json!({"batch_size": count})), &files).is_ok());+    }+    assert!(build_inpaint_request(&request(json!({})), &files).unwrap().num_images.is_none());+  }++  fn request(value: serde_json::Value) -> TauriGenerateImageRequest {+    serde_json::from_value(value).unwrap()+  }++  fn media(scene: Option<&str>, canvas: Option<&str>, mask: Option<&str>) -> SemanticMediaFiles {+    SemanticMediaFiles { scene_image_media_token: scene.map(MediaFileToken::new_from_str), canvas_image_media_token: canvas.map(MediaFileToken::new_from_str), inpainting_mask_image_media_token: mask.map(MediaFileToken::new_from_str) }+  }+}diff --git a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/mod.rs b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/mod.rs--- a/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/mod.rs+++ b/crates/desktop/artcraft/src/core/commands/generate/generate_image/providers/artcraft/legacy/mod.rs@@ -2,3 +2,4 @@ pub mod artcraft_flux_2_lora_angles; pub mod artcraft_flux_dev_juggernaut_inpaint; pub mod artcraft_flux_pro_kontext_edit; pub mod artcraft_qwen_edit_2511_angles;+pub mod artcraft_flux_pro_1_inpaint;diff --git a/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx b/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx--- a/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx+++ b/frontend/libs/components/pagedraw/src/lib/PageDraw.tsx@@ -50,6 +50,7 @@ import { } from "./components/Model3DOverlay"; import { CameraIcon, Maximize2Icon, RefreshCwIcon } from "lucide-react"; import type { PageDrawAdapter } from "./adapter";+import { useGenerationSubmission } from "./hooks/useGenerationSubmission";  const PAGE_ID: ModelPage = ModelPage.Canvas2D; @@ -417,11 +418,9 @@ const PageDraw = ({   const baseImageUrl = baseImageInfo?.url;    // Synchronous re-entry guard for the Generate flow. A ref (not state) is the-  // safety belt against duplicate paid generations: it flips before the first-  // await so a panic-clicked second click sees the in-flight value immediately,-  // independent of React state batching. The state below is only for the UI.-  const generateInFlightRef = useRef(false);-  const [isGenerating, setIsGenerating] = useState(false);+  // safety belt against duplicate paid generations: the hook locks before the+  // first await, reports failures, and releases the button for another attempt.+  const { isGenerating, runGeneration } = useGenerationSubmission();    const selectedImageModel: ImageModel | undefined =     useSelectedImageModel(PAGE_ID);@@ -828,10 +827,7 @@ const PageDraw = ({         selectedProvider?: GenerationProvider;       },     ) => {-      if (generateInFlightRef.current) return;-      generateInFlightRef.current = true;-      setIsGenerating(true);-      try {+      await runGeneration(async () => {         // Yield once so React paints the disabled button before the heavy         // canvas readback / encode / upload runs and locks the main thread.         await new Promise<void>((resolve) =>@@ -954,15 +950,14 @@ const PageDraw = ({             provider: options?.selectedProvider,           });         }-        if (result?.status === "success") {-          addPendingGeneration(subscriberId, generationCount);+        if (result?.status !== "success") {+          throw result;         }-      } finally {-        generateInFlightRef.current = false;-        setIsGenerating(false);-      }+        addPendingGeneration(subscriberId, generationCount);+      });     },     [+      runGeneration,       generationCount,       getCompositeFile,       getMaskArrayBuffer,diff --git a/frontend/libs/components/pagedraw/src/lib/hooks/useGenerationSubmission.spec.ts b/frontend/libs/components/pagedraw/src/lib/hooks/useGenerationSubmission.spec.tsnew file mode 100644--- /dev/null+++ b/frontend/libs/components/pagedraw/src/lib/hooks/useGenerationSubmission.spec.ts@@ -0,0 +1,50 @@+import { act, renderHook } from "@testing-library/react";+import { afterEach, describe, expect, it, vi } from "vitest";+import toast from "react-hot-toast";+import { useGenerationSubmission } from "./useGenerationSubmission";++vi.mock("react-hot-toast", () => ({ default: { error: vi.fn() } }));++afterEach(() => vi.restoreAllMocks());++describe("canvas generation submission", () => {+  it.each([+    [{ error_message: "Provider rejected the mask" }, "Provider rejected the mask"],+    [new Error("Mask encoding failed"), "Mask encoding failed"],+    ["Upload failed", "Upload failed"],+    [{ status: "server_error" }, "Failed to start image generation. Please try again."],+    [{ error_message: { unexpected: true } }, "Failed to start image generation. Please try again."],+  ])("shows failures and releases the generate button", async (error, expected) => {+    vi.spyOn(console, "error").mockImplementation(() => undefined);+    const { result } = renderHook(() => useGenerationSubmission());+    const addPendingGeneration = vi.fn();+    await act(async () => {+      await result.current.runGeneration(async () => {+        await Promise.reject(error);+        addPendingGeneration();+      });+    });+    expect(toast.error).toHaveBeenCalledWith(expected);+    expect(addPendingGeneration).not.toHaveBeenCalled();+    expect(result.current.isGenerating).toBe(false);++    await act(async () => {+      await result.current.runGeneration(async () => { addPendingGeneration(); });+    });+    expect(addPendingGeneration).toHaveBeenCalledOnce();+  });++  it("prevents duplicate enqueues while a submission is pending", async () => {+    const { result } = renderHook(() => useGenerationSubmission());+    let resolve!: () => void;+    const pending = new Promise<void>((done) => { resolve = done; });+    const enqueue = vi.fn(() => pending);+    let first!: Promise<void>;+    act(() => { first = result.current.runGeneration(enqueue); });+    expect(result.current.isGenerating).toBe(true);+    await act(async () => { await result.current.runGeneration(enqueue); });+    expect(enqueue).toHaveBeenCalledOnce();+    await act(async () => { resolve(); await first; });+    expect(result.current.isGenerating).toBe(false);+  });+});diff --git a/frontend/libs/components/pagedraw/src/lib/hooks/useGenerationSubmission.ts b/frontend/libs/components/pagedraw/src/lib/hooks/useGenerationSubmission.tsnew file mode 100644--- /dev/null+++ b/frontend/libs/components/pagedraw/src/lib/hooks/useGenerationSubmission.ts@@ -0,0 +1,38 @@+import { useCallback, useRef, useState } from "react";+import toast from "react-hot-toast";++const FALLBACK_MESSAGE = "Failed to start image generation. Please try again.";++/** Keep failed canvas submissions visible and allow a subsequent retry. */+export function useGenerationSubmission() {+  const inFlight = useRef(false);+  const [isGenerating, setIsGenerating] = useState(false);++  const runGeneration = useCallback(async (submit: () => Promise<void>) => {+    if (inFlight.current) return;+    inFlight.current = true;+    setIsGenerating(true);+    try {+      await submit();+    } catch (error) {+      console.error("Canvas generation failed", error);+      toast.error(generationErrorMessage(error));+    } finally {+      inFlight.current = false;+      setIsGenerating(false);+    }+  }, []);++  return { isGenerating, runGeneration };+}++function generationErrorMessage(error: unknown): string {+  if (typeof error === "string" && error.trim()) return error;+  if (error && typeof error === "object") {+    const details = error as { error_message?: unknown; message?: unknown };+    for (const message of [details.error_message, details.message]) {+      if (typeof message === "string" && message.trim()) return message;+    }+  }+  return FALLBACK_MESSAGE;+}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"