Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a904ff4e21 | ||
|
|
04e63414a3 | ||
|
|
bae363893b | ||
|
|
b7306877f1 |
@@ -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": "<base64 JPEG>" }
|
||||
//
|
||||
// Reference-image request:
|
||||
// - FLUX models: { "prompt": "...", "image_b64": "<base64>" }
|
||||
// - 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": "<base64>" } 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":"<base64>"} 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": "<base64>"} 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 {
|
||||
|
||||
@@ -1530,6 +1530,7 @@ export async function createComment(
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: crypto.randomUUID().replace(/-/g, '').slice(0, 15),
|
||||
slug,
|
||||
chapter,
|
||||
body,
|
||||
|
||||
@@ -90,17 +90,13 @@
|
||||
// Auto-advance carousel every CAROUSEL_INTERVAL ms when there are multiple books.
|
||||
// autoAdvanceSeed is bumped on manual swipe/dot to restart the interval.
|
||||
let autoAdvanceSeed = $state(0);
|
||||
// progressStart tracks when the current interval began (for the progress bar).
|
||||
let progressStart = $state(browser ? performance.now() : 0);
|
||||
|
||||
$effect(() => {
|
||||
if (heroBooks.length <= 1) return;
|
||||
const len = heroBooks.length;
|
||||
void autoAdvanceSeed; // restart when seed changes
|
||||
progressStart = browser ? performance.now() : 0;
|
||||
const id = setInterval(() => {
|
||||
heroIndex = (heroIndex + 1) % len;
|
||||
progressStart = browser ? performance.now() : 0;
|
||||
}, CAROUSEL_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
@@ -127,22 +123,6 @@
|
||||
resetAutoAdvance();
|
||||
}
|
||||
|
||||
// ── Progress bar animation ───────────────────────────────────────────────
|
||||
// rAF loop drives a 0→1 progress value that resets on each advance.
|
||||
let rafProgress = $state(0);
|
||||
$effect(() => {
|
||||
if (!browser || heroBooks.length <= 1) return;
|
||||
void autoAdvanceSeed; // re-subscribe so effect re-runs on manual nav
|
||||
void heroIndex;
|
||||
let raf: number;
|
||||
function tick() {
|
||||
rafProgress = Math.min((performance.now() - progressStart) / CAROUSEL_INTERVAL, 1);
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
function playChapter(slug: string, chapter: number) {
|
||||
audioStore.autoStartChapter = chapter;
|
||||
goto(`/books/${slug}/chapters/${chapter}`);
|
||||
@@ -210,10 +190,6 @@
|
||||
</svg>
|
||||
Listen
|
||||
</button>
|
||||
{#if heroBook.book.total_chapters > 0 && heroBook.chapter < heroBook.book.total_chapters}
|
||||
{@const ahead = heroBook.book.total_chapters - heroBook.chapter}
|
||||
<span class="text-xs text-(--color-muted) hidden sm:inline">{ahead} chapters ahead</span>
|
||||
{/if}
|
||||
{#each parseGenres(heroBook.book.genres).slice(0, 2) as genre}
|
||||
<span class="text-xs px-2 py-1 rounded-full bg-(--color-surface-3) text-(--color-muted)">{genre}</span>
|
||||
{/each}
|
||||
@@ -221,7 +197,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dot indicators with animated progress line under active dot -->
|
||||
<!-- Dot indicators -->
|
||||
{#if heroBooks.length > 1}
|
||||
<div class="flex items-center justify-center gap-2 mt-2.5">
|
||||
{#each heroBooks as _, i}
|
||||
@@ -229,21 +205,10 @@
|
||||
type="button"
|
||||
onclick={() => heroDot(i)}
|
||||
aria-label="Go to book {i + 1}"
|
||||
class="relative flex flex-col items-center gap-0.5 group/dot"
|
||||
>
|
||||
<!-- dot -->
|
||||
<span class="block rounded-full transition-all duration-300 {i === heroIndex
|
||||
? 'w-4 h-1.5 bg-(--color-brand)'
|
||||
: 'w-1.5 h-1.5 bg-(--color-border) group-hover/dot:bg-(--color-muted)'}"></span>
|
||||
<!-- progress line — only visible under the active dot -->
|
||||
{#if i === heroIndex}
|
||||
<span class="absolute -bottom-1.5 left-0 h-0.5 w-full bg-(--color-border) rounded-full overflow-hidden">
|
||||
<span
|
||||
class="block h-full bg-(--color-brand) rounded-full"
|
||||
style="width: {rafProgress * 100}%"
|
||||
></span>
|
||||
</span>
|
||||
{/if}
|
||||
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -256,9 +221,6 @@
|
||||
{#if streak > 0}
|
||||
<div class="mb-6 flex items-center gap-3 flex-wrap text-sm">
|
||||
<span class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||
<svg class="w-4 h-4 text-orange-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M13.5 0.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73l.03-.36C5.21 7.51 4 10.62 4 14c0 4.42 3.58 8 8 8s8-3.58 8-8C20 8.61 17.41 3.8 13.5.67zM11.71 19c-1.78 0-3.22-1.4-3.22-3.14 0-1.62 1.05-2.76 2.81-3.12 1.77-.36 3.6-1.21 4.62-2.58.39 1.29.59 2.65.59 4.04 0 2.65-2.15 4.8-4.8 4.8z"/>
|
||||
</svg>
|
||||
<span class="font-semibold text-(--color-text)">{streak}</span>
|
||||
<span class="text-(--color-muted)">day{streak !== 1 ? 's' : ''} reading</span>
|
||||
</span>
|
||||
@@ -293,12 +255,6 @@
|
||||
<span class="absolute bottom-1.5 right-1.5 text-xs bg-(--color-brand) text-(--color-surface) font-bold px-1.5 py-0.5 rounded">
|
||||
{m.home_chapter_badge({ n: String(chapter) })}
|
||||
</span>
|
||||
<!-- Chapters ahead badge -->
|
||||
{#if book.total_chapters > 0 && chapter < book.total_chapters}
|
||||
<span class="absolute top-1.5 left-1.5 text-xs bg-black/60 text-white font-medium px-1.5 py-0.5 rounded">
|
||||
{book.total_chapters - chapter} left
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
<!-- Listen button (hover overlay) -->
|
||||
@@ -338,7 +294,7 @@
|
||||
<svg class="w-8 h-8 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
</div>
|
||||
{/if}
|
||||
<span class="absolute top-1.5 right-1.5 text-xs bg-green-600/90 text-white font-bold px-1.5 py-0.5 rounded">✓ Done</span>
|
||||
<span class="absolute top-1.5 right-1.5 text-xs bg-green-600/90 text-white font-bold px-1.5 py-0.5 rounded">Done</span>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<h3 class="text-xs font-semibold text-(--color-text) line-clamp-2 leading-snug">{book.title ?? ''}</h3>
|
||||
@@ -597,6 +553,6 @@
|
||||
<span><span class="font-semibold text-(--color-text)">{data.stats.totalChapters.toLocaleString()}</span> {m.home_stat_chapters()}</span>
|
||||
{#if streak > 0}
|
||||
<span class="w-px h-4 bg-(--color-border)"></span>
|
||||
<span><span class="font-semibold text-(--color-text)">{streak}</span> day streak 🔥</span>
|
||||
<span><span class="font-semibold text-(--color-text)">{streak}</span> day streak</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -164,6 +164,7 @@
|
||||
let coverPreview = $state<string | null>(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<string | null>(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)' : ''}
|
||||
</button>
|
||||
{#if coverResult === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||
<span class="text-xs text-(--color-danger)">{coverErrorMsg || m.common_error()}</span>
|
||||
{:else if coverResult === 'saved'}
|
||||
<span class="text-xs text-green-400">{m.book_detail_admin_saved()}</span>
|
||||
{/if}
|
||||
@@ -1159,7 +1169,7 @@
|
||||
{m.book_detail_admin_generate()}
|
||||
</button>
|
||||
{#if chapterCoverResult === 'error'}
|
||||
<span class="text-xs text-(--color-danger)">{m.common_error()}</span>
|
||||
<span class="text-xs text-(--color-danger)">{chapterCoverErrorMsg || m.common_error()}</span>
|
||||
{:else if chapterCoverResult === 'saved'}
|
||||
<span class="text-xs text-green-400">{m.book_detail_admin_saved()}</span>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user