diff --git a/backend/internal/cfai/image.go b/backend/internal/cfai/image.go index bd09738..713b95f 100644 --- a/backend/internal/cfai/image.go +++ b/backend/internal/cfai/image.go @@ -4,17 +4,26 @@ // // POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model} // Authorization: Bearer {apiToken} -// Content-Type: application/json // -// Text-only request (all models): +// FLUX.2 models (flux-2-dev, flux-2-klein-4b, flux-2-klein-9b): // -// { "prompt": "...", "num_steps": 20 } +// Content-Type: multipart/form-data +// Fields: prompt, num_steps, width, height, guidance, image_b64 (optional) +// Response: { "image": "" } // -// Reference-image request: -// - FLUX models: { "prompt": "...", "image_b64": "" } -// - SD img2img: { "prompt": "...", "image": [r,g,b,a,...], "strength": 0.75 } +// Other models (flux-1-schnell, SDXL, SD 1.5): // -// All models return raw PNG bytes on success (Content-Type: image/png). +// Content-Type: application/json +// Body: { "prompt": "...", "num_steps": 20 } +// Response: { "image": "" } or raw bytes depending on model +// +// Reference-image request (FLUX.2): +// +// Same multipart form; include image_b64 field with base64-encoded reference. +// +// Reference-image request (SD img2img): +// +// JSON body: { "prompt": "...", "image": [r,g,b,a,...], "strength": 0.75 } // // Recommended models for LibNovel: // - Book covers (no reference): flux-2-dev, flux-2-klein-9b, lucid-origin @@ -35,7 +44,9 @@ import ( "image/png" _ "image/png" // register PNG decoder "io" + "mime/multipart" "net/http" + "strings" "time" ) @@ -173,23 +184,43 @@ func NewImageGen(accountID, apiToken string) ImageGenClient { } } +// requiresMultipart reports whether the model requires a multipart/form-data +// request body instead of JSON. FLUX.2 models on Cloudflare Workers AI changed +// their API to require multipart and return {"image":""} instead of +// raw image bytes. +func requiresMultipart(model ImageModel) bool { + switch model { + case ImageModelFlux2Dev, ImageModelFlux2Klein4B, ImageModelFlux2Klein9B: + return true + default: + return false + } +} + // GenerateImage generates an image from text only. func (c *imageGenHTTPClient) GenerateImage(ctx context.Context, req ImageRequest) ([]byte, error) { req = applyImageDefaults(req) - body := map[string]any{ - "prompt": req.Prompt, - "num_steps": req.NumSteps, + + // FLUX.2 multipart models use "steps"; JSON models use "num_steps". + stepsKey := "num_steps" + if requiresMultipart(req.Model) { + stepsKey = "steps" + } + + fields := map[string]any{ + "prompt": req.Prompt, + stepsKey: req.NumSteps, } if req.Width > 0 { - body["width"] = req.Width + fields["width"] = req.Width } if req.Height > 0 { - body["height"] = req.Height + fields["height"] = req.Height } if req.Guidance > 0 { - body["guidance"] = req.Guidance + fields["guidance"] = req.Guidance } - return c.callImageAPI(ctx, req.Model, body) + return c.callImageAPI(ctx, req.Model, fields, nil) } // refImageMaxDim is the maximum dimension (width or height) for reference images @@ -205,10 +236,37 @@ func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req req = applyImageDefaults(req) // Shrink the reference image if it exceeds the safe payload size. - // This avoids CF's 4 MB JSON body limit and reduces latency. refImage = resizeRefImage(refImage, refImageMaxDim) - var body map[string]any + // FLUX.2 multipart models use "steps"; JSON models use "num_steps". + stepsKey := "num_steps" + if requiresMultipart(req.Model) { + stepsKey = "steps" + } + + fields := map[string]any{ + "prompt": req.Prompt, + stepsKey: req.NumSteps, + } + if req.Width > 0 { + fields["width"] = req.Width + } + if req.Height > 0 { + fields["height"] = req.Height + } + if req.Guidance > 0 { + fields["guidance"] = req.Guidance + } + + if requiresMultipart(req.Model) { + // FLUX.2: reference image sent as base64 form field "image_b64". + fields["image_b64"] = base64.StdEncoding.EncodeToString(refImage) + if req.Strength > 0 { + fields["strength"] = req.Strength + } + return c.callImageAPI(ctx, req.Model, fields, nil) + } + if req.Model == ImageModelSD15Img2Img { pixels, err := decodeImageToRGBA(refImage) if err != nil { @@ -218,33 +276,17 @@ func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req if strength <= 0 { strength = 0.75 } - body = map[string]any{ - "prompt": req.Prompt, - "image": pixels, - "strength": strength, - "num_steps": req.NumSteps, - } - } else { - b64 := base64.StdEncoding.EncodeToString(refImage) - body = map[string]any{ - "prompt": req.Prompt, - "image_b64": b64, - "num_steps": req.NumSteps, - } - if req.Strength > 0 { - body["strength"] = req.Strength - } + fields["image"] = pixels + fields["strength"] = strength + return c.callImageAPI(ctx, req.Model, fields, nil) } - if req.Width > 0 { - body["width"] = req.Width + + // Other FLUX models: image_b64 JSON field. + fields["image_b64"] = base64.StdEncoding.EncodeToString(refImage) + if req.Strength > 0 { + fields["strength"] = req.Strength } - if req.Height > 0 { - body["height"] = req.Height - } - if req.Guidance > 0 { - body["guidance"] = req.Guidance - } - return c.callImageAPI(ctx, req.Model, body) + return c.callImageAPI(ctx, req.Model, fields, nil) } // Models returns all supported image model metadata. @@ -252,19 +294,56 @@ func (c *imageGenHTTPClient) Models() []ImageModelInfo { return AllImageModels() } -func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel, body map[string]any) ([]byte, error) { - encoded, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("cfai/image: marshal: %w", err) - } - url := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s", +func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel, fields map[string]any, _ []byte) ([]byte, error) { + cfURL := fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai/run/%s", c.accountID, string(model)) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded)) + + var ( + bodyReader io.Reader + contentType string + ) + + if requiresMultipart(model) { + // Build a multipart/form-data body from the fields map. + // All values are serialised to their string representation. + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + for k, v := range fields { + var strVal string + switch tv := v.(type) { + case string: + strVal = tv + default: + encoded, merr := json.Marshal(tv) + if merr != nil { + return nil, fmt.Errorf("cfai/image: marshal field %q: %w", k, merr) + } + strVal = strings.Trim(string(encoded), `"`) + } + if werr := mw.WriteField(k, strVal); werr != nil { + return nil, fmt.Errorf("cfai/image: write field %q: %w", k, werr) + } + } + if cerr := mw.Close(); cerr != nil { + return nil, fmt.Errorf("cfai/image: close multipart writer: %w", cerr) + } + bodyReader = &buf + contentType = mw.FormDataContentType() + } else { + encoded, merr := json.Marshal(fields) + if merr != nil { + return nil, fmt.Errorf("cfai/image: marshal: %w", merr) + } + bodyReader = bytes.NewReader(encoded) + contentType = "application/json" + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfURL, bodyReader) if err != nil { return nil, fmt.Errorf("cfai/image: build request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.apiToken) - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", contentType) resp, err := c.http.Do(req) if err != nil { @@ -272,20 +351,38 @@ func (c *imageGenHTTPClient) callImageAPI(ctx context.Context, model ImageModel, } defer resp.Body.Close() + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("cfai/image: read response: %w", err) + } + if resp.StatusCode != http.StatusOK { - errBody, _ := io.ReadAll(resp.Body) - msg := string(errBody) + msg := string(respBody) if len(msg) > 300 { msg = msg[:300] } return nil, fmt.Errorf("cfai/image: model %s returned %d: %s", model, resp.StatusCode, msg) } - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("cfai/image: read response: %w", err) + // Try to parse as {"image": ""} first (FLUX.2 and newer models). + // Fall back to treating the body as raw image bytes for legacy models. + var jsonResp struct { + Image string `json:"image"` } - return data, nil + if jerr := json.Unmarshal(respBody, &jsonResp); jerr == nil && jsonResp.Image != "" { + imgBytes, decErr := base64.StdEncoding.DecodeString(jsonResp.Image) + if decErr != nil { + // Try raw (no padding) base64 + imgBytes, decErr = base64.RawStdEncoding.DecodeString(jsonResp.Image) + if decErr != nil { + return nil, fmt.Errorf("cfai/image: decode base64 response: %w", decErr) + } + } + return imgBytes, nil + } + + // Legacy: model returned raw image bytes directly. + return respBody, nil } func applyImageDefaults(req ImageRequest) ImageRequest { diff --git a/ui/src/routes/books/[slug]/+page.svelte b/ui/src/routes/books/[slug]/+page.svelte index 01ddb75..3e3d524 100644 --- a/ui/src/routes/books/[slug]/+page.svelte +++ b/ui/src/routes/books/[slug]/+page.svelte @@ -164,6 +164,7 @@ let coverPreview = $state(null); let coverSaving = $state(false); let coverResult = $state<'saved' | 'error' | ''>(''); + let coverErrorMsg = $state(''); let coverPromptOpen = $state(false); function buildCoverPrompt(): string { @@ -186,6 +187,7 @@ coverGenerating = true; coverPreview = null; coverResult = ''; + coverErrorMsg = ''; const promptToUse = coverPrompt.trim() || buildCoverPrompt(); try { let res: Response; @@ -210,22 +212,25 @@ } else { res = await fetch('/api/admin/image-gen', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse }) - }); - } - if (res.ok) { - const d = await res.json(); - coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null; - } else { - coverResult = 'error'; - } - } catch { - coverResult = 'error'; - } finally { - coverGenerating = false; + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug, type: 'cover', prompt: promptToUse }) + }); } + if (res.ok) { + const d = await res.json(); + coverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null; + } else { + const d = await res.json().catch(() => ({})); + coverErrorMsg = (d as any).error ?? ''; + coverResult = 'error'; + } + } catch (e: any) { + coverErrorMsg = e?.message ?? ''; + coverResult = 'error'; + } finally { + coverGenerating = false; } +} async function saveCover() { const slug = data.book?.slug; @@ -258,6 +263,7 @@ let chapterCoverGenerating = $state(false); let chapterCoverPreview = $state(null); let chapterCoverResult = $state<'saved' | 'error' | ''>(''); + let chapterCoverErrorMsg = $state(''); let chapterCoverSaving = $state(false); let chapterCoverPrompt = $state(''); @@ -269,6 +275,7 @@ chapterCoverGenerating = true; chapterCoverPreview = null; chapterCoverResult = ''; + chapterCoverErrorMsg = ''; const promptToUse = chapterCoverPrompt.trim() || `Chapter ${n} illustration for "${data.book?.title ?? slug}". Dramatic scene, vivid colors, detailed art, cinematic lighting.`; try { const res = await fetch('/api/admin/image-gen', { @@ -280,9 +287,12 @@ const d = await res.json(); chapterCoverPreview = d.image_b64 ? `data:${d.content_type ?? 'image/png'};base64,${d.image_b64}` : null; } else { + const d = await res.json().catch(() => ({})); + chapterCoverErrorMsg = (d as any).error ?? ''; chapterCoverResult = 'error'; } - } catch { + } catch (e: any) { + chapterCoverErrorMsg = e?.message ?? ''; chapterCoverResult = 'error'; } finally { chapterCoverGenerating = false; @@ -1101,7 +1111,7 @@ {m.book_detail_admin_generate()}{coverUseAsRef ? ' (img2img)' : ''} {#if coverResult === 'error'} - {m.common_error()} + {coverErrorMsg || m.common_error()} {:else if coverResult === 'saved'} {m.book_detail_admin_saved()} {/if} @@ -1159,7 +1169,7 @@ {m.book_detail_admin_generate()} {#if chapterCoverResult === 'error'} - {m.common_error()} + {chapterCoverErrorMsg || m.common_error()} {:else if chapterCoverResult === 'saved'} {m.book_detail_admin_saved()} {/if}