diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index bfbdc50..835803b 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -124,6 +124,15 @@ func run() error { log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — CF AI voices unavailable in backend") } + // ── Cloudflare Workers AI Image Generation ──────────────────────────────── + var imageGenClient cfai.ImageGenClient + if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" { + imageGenClient = cfai.NewImageGen(cfg.CFAI.AccountID, cfg.CFAI.APIToken) + log.Info("cloudflare AI image generation enabled") + } else { + log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — image generation unavailable") + } + // ── Meilisearch (search reads only; indexing is the runner's job) ──────── var searchIndex meili.Client if cfg.Meilisearch.URL != "" { @@ -174,6 +183,7 @@ func run() error { Kokoro: kokoroClient, PocketTTS: pocketTTSClient, CFAI: cfaiClient, + ImageGen: imageGenClient, Log: log, }, ) diff --git a/backend/internal/backend/handlers_image.go b/backend/internal/backend/handlers_image.go new file mode 100644 index 0000000..2862532 --- /dev/null +++ b/backend/internal/backend/handlers_image.go @@ -0,0 +1,234 @@ +package backend + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/libnovel/backend/internal/cfai" +) + +// handleAdminImageGenModels handles GET /api/admin/image-gen/models. +// Returns the list of supported Cloudflare AI image generation models. +func (s *Server) handleAdminImageGenModels(w http.ResponseWriter, r *http.Request) { + if s.deps.ImageGen == nil { + jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)") + return + } + models := s.deps.ImageGen.Models() + writeJSON(w, 0, map[string]any{"models": models}) +} + +// imageGenRequest is the JSON body for POST /api/admin/image-gen. +type imageGenRequest struct { + // Prompt is the text description of the desired image. + Prompt string `json:"prompt"` + + // Model is the CF Workers AI model ID (e.g. "@cf/black-forest-labs/flux-2-dev"). + // Defaults to the recommended model for the given type. + Model string `json:"model"` + + // Type is either "cover" or "chapter". + Type string `json:"type"` + + // Slug is the book slug. Required for cover; required for chapter. + Slug string `json:"slug"` + + // Chapter number (1-based). Required when type == "chapter". + Chapter int `json:"chapter"` + + // ReferenceImageB64 is an optional base64-encoded PNG/JPEG reference image. + // When present the img2img path is used. + ReferenceImageB64 string `json:"reference_image_b64"` + + // NumSteps overrides inference steps (default 20). + NumSteps int `json:"num_steps"` + + // Width / Height override output dimensions (0 = model default). + Width int `json:"width"` + Height int `json:"height"` + + // Guidance overrides prompt guidance scale (0 = model default). + Guidance float64 `json:"guidance"` + + // Strength for img2img: 0.0–1.0, default 0.75. + Strength float64 `json:"strength"` + + // SaveToCover when true stores the result as the book cover in MinIO + // (overwriting any existing cover) and sets the book's cover URL. + // Only valid when type == "cover". + SaveToCover bool `json:"save_to_cover"` +} + +// imageGenResponse is the JSON body returned by POST /api/admin/image-gen. +type imageGenResponse struct { + // ImageB64 is the generated image as a base64-encoded PNG string. + ImageB64 string `json:"image_b64"` + // ContentType is "image/png" or "image/jpeg". + ContentType string `json:"content_type"` + // Saved indicates whether the image was persisted to MinIO. + Saved bool `json:"saved"` + // CoverURL is the URL the cover is now served from (only set when Saved==true). + CoverURL string `json:"cover_url,omitempty"` + // Model is the model that was used. + Model string `json:"model"` + // Bytes is the raw image size in bytes. + Bytes int `json:"bytes"` +} + +// handleAdminImageGen handles POST /api/admin/image-gen. +// +// Generates an image using Cloudflare Workers AI and optionally stores it. +// Multipart/form-data is also accepted so the reference image can be uploaded +// directly; otherwise the reference is expected as base64 JSON. +func (s *Server) handleAdminImageGen(w http.ResponseWriter, r *http.Request) { + if s.deps.ImageGen == nil { + jsonError(w, http.StatusServiceUnavailable, "image generation not configured (CFAI_ACCOUNT_ID/CFAI_API_TOKEN missing)") + return + } + + var req imageGenRequest + var refImageData []byte + + ct := r.Header.Get("Content-Type") + if strings.HasPrefix(ct, "multipart/form-data") { + // Multipart: parse JSON fields from a "json" part + optional "reference" file part. + if err := r.ParseMultipartForm(32 << 20); err != nil { + jsonError(w, http.StatusBadRequest, "parse multipart: "+err.Error()) + return + } + if jsonPart := r.FormValue("json"); jsonPart != "" { + if err := json.Unmarshal([]byte(jsonPart), &req); err != nil { + jsonError(w, http.StatusBadRequest, "parse json field: "+err.Error()) + return + } + } + if f, _, err := r.FormFile("reference"); err == nil { + defer f.Close() + refImageData, _ = io.ReadAll(f) + } + } else { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonError(w, http.StatusBadRequest, "parse body: "+err.Error()) + return + } + if req.ReferenceImageB64 != "" { + var decErr error + refImageData, decErr = base64.StdEncoding.DecodeString(req.ReferenceImageB64) + if decErr != nil { + // Try std without padding + refImageData, decErr = base64.RawStdEncoding.DecodeString(req.ReferenceImageB64) + if decErr != nil { + jsonError(w, http.StatusBadRequest, "decode reference_image_b64: "+decErr.Error()) + return + } + } + } + } + + if strings.TrimSpace(req.Prompt) == "" { + jsonError(w, http.StatusBadRequest, "prompt is required") + return + } + if req.Type != "cover" && req.Type != "chapter" { + jsonError(w, http.StatusBadRequest, `type must be "cover" or "chapter"`) + return + } + if req.Slug == "" { + jsonError(w, http.StatusBadRequest, "slug is required") + return + } + if req.Type == "chapter" && req.Chapter <= 0 { + jsonError(w, http.StatusBadRequest, "chapter must be > 0 when type is chapter") + return + } + + // Resolve model + model := cfai.ImageModel(req.Model) + if model == "" { + if req.Type == "cover" { + model = cfai.DefaultImageModel + } else { + model = cfai.ImageModelFlux2Klein4B + } + } + + imgReq := cfai.ImageRequest{ + Prompt: req.Prompt, + Model: model, + NumSteps: req.NumSteps, + Width: req.Width, + Height: req.Height, + Guidance: req.Guidance, + Strength: req.Strength, + } + + s.deps.Log.Info("admin: image gen requested", + "type", req.Type, "slug", req.Slug, "chapter", req.Chapter, + "model", model, "has_reference", len(refImageData) > 0) + + var imgData []byte + var genErr error + if len(refImageData) > 0 { + imgData, genErr = s.deps.ImageGen.GenerateImageFromReference(r.Context(), imgReq, refImageData) + } else { + imgData, genErr = s.deps.ImageGen.GenerateImage(r.Context(), imgReq) + } + if genErr != nil { + s.deps.Log.Error("admin: image gen failed", "err", genErr) + jsonError(w, http.StatusBadGateway, "image generation failed: "+genErr.Error()) + return + } + + contentType := sniffImageContentType(imgData) + + // ── Optional persistence ────────────────────────────────────────────────── + var saved bool + var coverURL string + + if req.SaveToCover && req.Type == "cover" && s.deps.CoverStore != nil { + if err := s.deps.CoverStore.PutCover(r.Context(), req.Slug, imgData, contentType); err != nil { + s.deps.Log.Error("admin: save generated cover failed", "slug", req.Slug, "err", err) + // Non-fatal: still return the image + } else { + saved = true + coverURL = fmt.Sprintf("/api/cover/local/%s", req.Slug) + s.deps.Log.Info("admin: generated cover saved", "slug", req.Slug, "bytes", len(imgData)) + } + } + + // Encode result as base64 + b64 := base64.StdEncoding.EncodeToString(imgData) + + writeJSON(w, 0, imageGenResponse{ + ImageB64: b64, + ContentType: contentType, + Saved: saved, + CoverURL: coverURL, + Model: string(model), + Bytes: len(imgData), + }) +} + +// sniffImageContentType returns the MIME type of the image bytes. +func sniffImageContentType(data []byte) string { + if len(data) >= 4 { + // PNG: 0x89 P N G + if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4e && data[3] == 0x47 { + return "image/png" + } + // JPEG: FF D8 FF + if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { + return "image/jpeg" + } + // WebP: RIFF....WEBP + if len(data) >= 12 && data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' && + data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' { + return "image/webp" + } + } + return "image/png" +} diff --git a/backend/internal/backend/server.go b/backend/internal/backend/server.go index 53d7a1f..a524ffd 100644 --- a/backend/internal/backend/server.go +++ b/backend/internal/backend/server.go @@ -73,6 +73,9 @@ type Dependencies struct { // CFAI is the Cloudflare Workers AI TTS client (used for voice sample // generation and audio-stream live TTS; audio task generation is done by the runner). CFAI cfai.Client + // ImageGen is the Cloudflare Workers AI image generation client. + // If nil, image generation endpoints return 503. + ImageGen cfai.ImageGenClient // Log is the structured logger. Log *slog.Logger } @@ -183,6 +186,10 @@ func (s *Server) ListenAndServe(ctx context.Context) error { mux.HandleFunc("POST /api/admin/audio/bulk", s.handleAdminAudioBulk) mux.HandleFunc("POST /api/admin/audio/cancel-bulk", s.handleAdminAudioCancelBulk) + // Admin image generation endpoints + mux.HandleFunc("GET /api/admin/image-gen/models", s.handleAdminImageGenModels) + mux.HandleFunc("POST /api/admin/image-gen", s.handleAdminImageGen) + // Voices list mux.HandleFunc("GET /api/voices", s.handleVoices) diff --git a/backend/internal/cfai/image.go b/backend/internal/cfai/image.go new file mode 100644 index 0000000..264a435 --- /dev/null +++ b/backend/internal/cfai/image.go @@ -0,0 +1,312 @@ +// Image generation via Cloudflare Workers AI text-to-image models. +// +// API reference: +// +// POST https://api.cloudflare.com/client/v4/accounts/{accountID}/ai/run/{model} +// Authorization: Bearer {apiToken} +// Content-Type: application/json +// +// Text-only request (all models): +// +// { "prompt": "...", "num_steps": 20 } +// +// Reference-image request: +// - FLUX models: { "prompt": "...", "image_b64": "" } +// - SD img2img: { "prompt": "...", "image": [r,g,b,a,...], "strength": 0.75 } +// +// All models return raw PNG bytes on success (Content-Type: image/png). +// +// Recommended models for LibNovel: +// - Book covers (no reference): flux-2-dev, flux-2-klein-9b, lucid-origin +// - Chapter images (speed): flux-2-klein-4b, flux-1-schnell +// - With reference image: flux-2-dev, flux-2-klein-9b, sd-v1-5-img2img +package cfai + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "image" + _ "image/jpeg" // register JPEG decoder + _ "image/png" // register PNG decoder + "io" + "net/http" + "time" +) + +// ImageModel identifies a Cloudflare Workers AI text-to-image model. +type ImageModel string + +const ( + // ImageModelFlux2Dev — best quality, multi-reference. Recommended for covers. + ImageModelFlux2Dev ImageModel = "@cf/black-forest-labs/flux-2-dev" + // ImageModelFlux2Klein9B — 9B params, multi-reference. Good for covers. + ImageModelFlux2Klein9B ImageModel = "@cf/black-forest-labs/flux-2-klein-9b" + // ImageModelFlux2Klein4B — ultra-fast, unified gen+edit. Recommended for chapters. + ImageModelFlux2Klein4B ImageModel = "@cf/black-forest-labs/flux-2-klein-4b" + // ImageModelFlux1Schnell — fastest, text-only. Good for quick illustrations. + ImageModelFlux1Schnell ImageModel = "@cf/black-forest-labs/flux-1-schnell" + // ImageModelSDXLLightning — fast 1024px generation. + ImageModelSDXLLightning ImageModel = "@cf/bytedance/stable-diffusion-xl-lightning" + // ImageModelSD15Img2Img — explicit img2img with flat RGBA reference. + ImageModelSD15Img2Img ImageModel = "@cf/runwayml/stable-diffusion-v1-5-img2img" + // ImageModelSDXLBase — Stability AI SDXL base. + ImageModelSDXLBase ImageModel = "@cf/stabilityai/stable-diffusion-xl-base-1.0" + // ImageModelLucidOrigin — Leonardo AI; strong prompt adherence. + ImageModelLucidOrigin ImageModel = "@cf/leonardo/lucid-origin" + // ImageModelPhoenix10 — Leonardo AI; accurate text rendering. + ImageModelPhoenix10 ImageModel = "@cf/leonardo/phoenix-1.0" + + // DefaultImageModel is the default model for book-cover generation. + DefaultImageModel = ImageModelFlux2Dev +) + +// ImageModelInfo describes a single image generation model. +type ImageModelInfo struct { + ID string `json:"id"` + Label string `json:"label"` + Provider string `json:"provider"` + SupportsRef bool `json:"supports_ref"` + RecommendedFor []string `json:"recommended_for"` // "cover" and/or "chapter" + Description string `json:"description"` +} + +// AllImageModels returns metadata about every supported image model. +func AllImageModels() []ImageModelInfo { + return []ImageModelInfo{ + { + ID: string(ImageModelFlux2Dev), Label: "FLUX.2 Dev", Provider: "Black Forest Labs", + SupportsRef: true, RecommendedFor: []string{"cover"}, + Description: "Best quality; multi-reference editing. Recommended for book covers.", + }, + { + ID: string(ImageModelFlux2Klein9B), Label: "FLUX.2 Klein 9B", Provider: "Black Forest Labs", + SupportsRef: true, RecommendedFor: []string{"cover"}, + Description: "9B parameters with multi-reference support.", + }, + { + ID: string(ImageModelFlux2Klein4B), Label: "FLUX.2 Klein 4B", Provider: "Black Forest Labs", + SupportsRef: true, RecommendedFor: []string{"chapter"}, + Description: "Ultra-fast unified gen+edit. Recommended for chapter images.", + }, + { + ID: string(ImageModelFlux1Schnell), Label: "FLUX.1 Schnell", Provider: "Black Forest Labs", + SupportsRef: false, RecommendedFor: []string{"chapter"}, + Description: "Fastest inference. Good for quick chapter illustrations.", + }, + { + ID: string(ImageModelSDXLLightning), Label: "SDXL Lightning", Provider: "ByteDance", + SupportsRef: false, RecommendedFor: []string{"chapter"}, + Description: "Lightning-fast 1024px images in a few steps.", + }, + { + ID: string(ImageModelSD15Img2Img), Label: "SD 1.5 img2img", Provider: "RunwayML", + SupportsRef: true, RecommendedFor: []string{"cover", "chapter"}, + Description: "Explicit img2img: generates from a reference image + prompt.", + }, + { + ID: string(ImageModelSDXLBase), Label: "SDXL Base 1.0", Provider: "Stability AI", + SupportsRef: false, RecommendedFor: []string{"cover"}, + Description: "Stable Diffusion XL base model.", + }, + { + ID: string(ImageModelLucidOrigin), Label: "Lucid Origin", Provider: "Leonardo AI", + SupportsRef: false, RecommendedFor: []string{"cover"}, + Description: "Highly prompt-responsive; strong graphic design and HD renders.", + }, + { + ID: string(ImageModelPhoenix10), Label: "Phoenix 1.0", Provider: "Leonardo AI", + SupportsRef: false, RecommendedFor: []string{"cover"}, + Description: "Exceptional prompt adherence; accurate text rendering.", + }, + } +} + +// ImageRequest is the input to GenerateImage / GenerateImageFromReference. +type ImageRequest struct { + // Prompt is the text description of the desired image. + Prompt string + // Model is the CF Workers AI model. Defaults to DefaultImageModel when empty. + Model ImageModel + // NumSteps controls inference quality (default 20). Range: 1–20. + NumSteps int + // Width and Height in pixels. 0 = model default (typically 1024x1024). + Width, Height int + // Guidance controls prompt adherence (default 7.5). + Guidance float64 + // Strength for img2img: 0.0 = copy reference, 1.0 = ignore reference (default 0.75). + Strength float64 +} + +// ImageGenClient generates images via Cloudflare Workers AI. +type ImageGenClient interface { + // GenerateImage creates an image from a text prompt only. + // Returns raw PNG bytes. + GenerateImage(ctx context.Context, req ImageRequest) ([]byte, error) + + // GenerateImageFromReference creates an image from a text prompt + reference image. + // refImage should be PNG or JPEG bytes. Returns raw PNG bytes. + GenerateImageFromReference(ctx context.Context, req ImageRequest, refImage []byte) ([]byte, error) + + // Models returns metadata about all supported image models. + Models() []ImageModelInfo +} + +// imageGenHTTPClient is the concrete CF AI image generation client. +type imageGenHTTPClient struct { + accountID string + apiToken string + http *http.Client +} + +// NewImageGen returns an ImageGenClient for the given Cloudflare account. +func NewImageGen(accountID, apiToken string) ImageGenClient { + return &imageGenHTTPClient{ + accountID: accountID, + apiToken: apiToken, + http: &http.Client{Timeout: 5 * time.Minute}, + } +} + +// 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, + } + if req.Width > 0 { + body["width"] = req.Width + } + if req.Height > 0 { + body["height"] = req.Height + } + if req.Guidance > 0 { + body["guidance"] = req.Guidance + } + return c.callImageAPI(ctx, req.Model, body) +} + +// GenerateImageFromReference generates an image from a text prompt + reference image. +func (c *imageGenHTTPClient) GenerateImageFromReference(ctx context.Context, req ImageRequest, refImage []byte) ([]byte, error) { + if len(refImage) == 0 { + return c.GenerateImage(ctx, req) + } + req = applyImageDefaults(req) + + var body map[string]any + if req.Model == ImageModelSD15Img2Img { + pixels, err := decodeImageToRGBA(refImage) + if err != nil { + return nil, fmt.Errorf("cfai/image: decode reference: %w", err) + } + strength := req.Strength + 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 + } + } + if req.Width > 0 { + body["width"] = req.Width + } + if req.Height > 0 { + body["height"] = req.Height + } + if req.Guidance > 0 { + body["guidance"] = req.Guidance + } + return c.callImageAPI(ctx, req.Model, body) +} + +// Models returns all supported image model metadata. +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", + c.accountID, string(model)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(encoded)) + 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") + + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("cfai/image: http: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + errBody, _ := io.ReadAll(resp.Body) + msg := string(errBody) + 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) + } + return data, nil +} + +func applyImageDefaults(req ImageRequest) ImageRequest { + if req.Model == "" { + req.Model = DefaultImageModel + } + if req.NumSteps <= 0 { + req.NumSteps = 20 + } + return req +} + +// decodeImageToRGBA decodes PNG/JPEG bytes to a flat []uint8 RGBA pixel array +// required by the stable-diffusion-v1-5-img2img model. +func decodeImageToRGBA(data []byte) ([]uint8, error) { + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("decode image: %w", err) + } + bounds := img.Bounds() + w := bounds.Max.X - bounds.Min.X + h := bounds.Max.Y - bounds.Min.Y + pixels := make([]uint8, w*h*4) + idx := 0 + for y := bounds.Min.Y; y < bounds.Max.Y; y++ { + for x := bounds.Min.X; x < bounds.Max.X; x++ { + r, g, b, a := img.At(x, y).RGBA() + pixels[idx] = uint8(r >> 8) + pixels[idx+1] = uint8(g >> 8) + pixels[idx+2] = uint8(b >> 8) + pixels[idx+3] = uint8(a >> 8) + idx += 4 + } + } + return pixels, nil +} diff --git a/ui/messages/en.json b/ui/messages/en.json index 11b6249..54c11f0 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -362,6 +362,7 @@ "admin_nav_audio": "Audio", "admin_nav_translation": "Translation", "admin_nav_changelog": "Changelog", + "admin_nav_image_gen": "Image Gen", "admin_nav_feedback": "Feedback", "admin_nav_errors": "Errors", "admin_nav_analytics": "Analytics", diff --git a/ui/messages/fr.json b/ui/messages/fr.json index f0cfa2b..46d0d33 100644 --- a/ui/messages/fr.json +++ b/ui/messages/fr.json @@ -362,6 +362,7 @@ "admin_nav_audio": "Audio", "admin_nav_translation": "Traduction", "admin_nav_changelog": "Modifications", + "admin_nav_image_gen": "Image Gen", "admin_nav_feedback": "Retours", "admin_nav_errors": "Erreurs", "admin_nav_analytics": "Analytique", diff --git a/ui/messages/id.json b/ui/messages/id.json index 2e52bad..fbee9f0 100644 --- a/ui/messages/id.json +++ b/ui/messages/id.json @@ -362,6 +362,7 @@ "admin_nav_audio": "Audio", "admin_nav_translation": "Terjemahan", "admin_nav_changelog": "Perubahan", + "admin_nav_image_gen": "Image Gen", "admin_nav_feedback": "Masukan", "admin_nav_errors": "Kesalahan", "admin_nav_analytics": "Analitik", diff --git a/ui/messages/pt.json b/ui/messages/pt.json index 283788c..828ec0c 100644 --- a/ui/messages/pt.json +++ b/ui/messages/pt.json @@ -362,6 +362,7 @@ "admin_nav_audio": "Áudio", "admin_nav_translation": "Tradução", "admin_nav_changelog": "Alterações", + "admin_nav_image_gen": "Image Gen", "admin_nav_feedback": "Feedback", "admin_nav_errors": "Erros", "admin_nav_analytics": "Análise", diff --git a/ui/messages/ru.json b/ui/messages/ru.json index 91a0fa5..62bf672 100644 --- a/ui/messages/ru.json +++ b/ui/messages/ru.json @@ -362,6 +362,7 @@ "admin_nav_audio": "Аудио", "admin_nav_translation": "Перевод", "admin_nav_changelog": "Изменения", + "admin_nav_image_gen": "Image Gen", "admin_nav_feedback": "Отзывы", "admin_nav_errors": "Ошибки", "admin_nav_analytics": "Аналитика", diff --git a/ui/src/routes/admin/+layout.svelte b/ui/src/routes/admin/+layout.svelte index d60acb8..4533092 100644 --- a/ui/src/routes/admin/+layout.svelte +++ b/ui/src/routes/admin/+layout.svelte @@ -6,7 +6,8 @@ { href: '/admin/scrape', label: () => m.admin_nav_scrape() }, { href: '/admin/audio', label: () => m.admin_nav_audio() }, { href: '/admin/translation', label: () => m.admin_nav_translation() }, - { href: '/admin/changelog', label: () => m.admin_nav_changelog() } + { href: '/admin/changelog', label: () => m.admin_nav_changelog() }, + { href: '/admin/image-gen', label: () => m.admin_nav_image_gen() } ]; const externalLinks = [ diff --git a/ui/src/routes/admin/image-gen/+page.server.ts b/ui/src/routes/admin/image-gen/+page.server.ts new file mode 100644 index 0000000..b44793d --- /dev/null +++ b/ui/src/routes/admin/image-gen/+page.server.ts @@ -0,0 +1,28 @@ +import type { PageServerLoad } from './$types'; +import { backendFetch } from '$lib/server/scraper'; +import { log } from '$lib/server/logger'; + +export interface ImageModelInfo { + id: string; + label: string; + provider: string; + supports_ref: boolean; + recommended_for: string[]; // "cover" | "chapter" + description: string; +} + +export const load: PageServerLoad = async () => { + // parent layout already guards admin role + try { + const res = await backendFetch('/api/admin/image-gen/models'); + if (!res.ok) { + log.warn('admin/image-gen', 'failed to load models', { status: res.status }); + return { models: [] as ImageModelInfo[] }; + } + const data = await res.json(); + return { models: (data.models ?? []) as ImageModelInfo[] }; + } catch (e) { + log.warn('admin/image-gen', 'backend unreachable', { err: String(e) }); + return { models: [] as ImageModelInfo[] }; + } +}; diff --git a/ui/src/routes/admin/image-gen/+page.svelte b/ui/src/routes/admin/image-gen/+page.svelte new file mode 100644 index 0000000..b4183d9 --- /dev/null +++ b/ui/src/routes/admin/image-gen/+page.svelte @@ -0,0 +1,676 @@ + + + + Image Gen — Admin + + +
+ +
+

Image Generation

+

+ Generate book covers and chapter images using Cloudflare Workers AI. +

+
+ + +
+ +
+ +
+ {#each (['cover', 'chapter'] as const) as t} + + {/each} +
+ + +
+
+ + +
+ + {#if imageType === 'chapter'} +
+ + +
+ {/if} +
+ + +
+ + + {#if selectedModelInfo} +

{selectedModelInfo.description}

+ {/if} + {#if refWarning} +

{refWarning}

+ {/if} +
+ + +
+
+ + +
+ +
+ + +
+

+ Reference image (optional, img2img) +

+ {#if referenceFile && referencePreviewUrl} +
+ Reference +
+

{referenceFile.name}

+

{fmtBytes(referenceFile.size)}

+
+ +
+ {:else} + + + {/if} +
+ + +
+ + + {#if showAdvanced} +
+ +
+
+ + {numSteps} +
+ +
+ + +
+
+ + {guidance.toFixed(1)} +
+ +
+ + + {#if referenceFile} +
+
+ + {strength.toFixed(2)} +
+ +

0 = copy reference · 1 = ignore reference

+
+ {/if} + + +
+
+ + +
+
+ + +
+
+
+ {/if} +
+ + + + + {#if genError} +

{genError}

+ {/if} +
+ + +
+ {#if result} +
+ + Generated image + + +
+
+
+

Model

+

+ {models.find((m) => m.id === result!.model)?.label ?? result.model} +

+
+
+

Size

+

{fmtBytes(result.bytes)}

+
+
+

Time

+

{fmtElapsed(result.elapsedMs)}

+
+
+ + {#if result.saved} +

+ Cover saved → + {result.coverUrl} +

+ {/if} + + {#if saveSuccess && !result.saved} +

Cover saved successfully.

+ {/if} + {#if saveError} +

{saveError}

+ {/if} + + +
+ + + {#if result.imageType === 'cover'} + + {/if} +
+
+
+ {:else if generating} + +
+
+ + + + +

Generating… {fmtElapsed(elapsedMs)}

+
+
+ {:else} + +
+

Generated image will appear here

+
+ {/if} + + + {#if history.length > 0} +
+

Session history

+
+ {#each history as h, i} + + {/each} +
+
+ {/if} +
+
+
diff --git a/ui/src/routes/api/admin/image-gen/+server.ts b/ui/src/routes/api/admin/image-gen/+server.ts new file mode 100644 index 0000000..a86008f --- /dev/null +++ b/ui/src/routes/api/admin/image-gen/+server.ts @@ -0,0 +1,46 @@ +/** + * POST /api/admin/image-gen + * + * Admin-only proxy to the Go backend's image generation endpoint. + * Transparently forwards the request body (JSON or multipart/form-data) + * and returns the JSON response containing the base64-encoded image. + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; + +export const POST: RequestHandler = async ({ request, locals }) => { + if (!locals.user || locals.user.role !== 'admin') { + throw error(403, 'Forbidden'); + } + + const ct = request.headers.get('content-type') ?? ''; + + let res: Response; + try { + if (ct.includes('multipart/form-data')) { + // Forward raw body bytes; let the backend parse multipart + const body = await request.arrayBuffer(); + res = await backendFetch('/api/admin/image-gen', { + method: 'POST', + headers: { 'content-type': ct }, + body + }); + } else { + const body = await request.text(); + res = await backendFetch('/api/admin/image-gen', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body + }); + } + } catch (e) { + log.error('admin/image-gen', 'backend proxy error', { err: String(e) }); + throw error(502, 'Could not reach backend'); + } + + const data = await res.json().catch(() => ({})); + return json(data, { status: res.status }); +};