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
@@ -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)));+ }+}@@ -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));+ }+}@@ -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 }new file mode 100644@@ -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) }+ }+}@@ -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;@@ -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,new file mode 100644@@ -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);+ });+});new file mode 100644@@ -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;+}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"