diff --git a/server/src/routes/books.ts b/server/src/routes/books.ts index 609a13f..f7747e8 100644 --- a/server/src/routes/books.ts +++ b/server/src/routes/books.ts @@ -234,6 +234,7 @@ router.get("/:id/generation-params", (req: Request, res: Response) => { generateAudio: payload.generateAudio !== false, theme: payload.theme || null, customTheme: payload.customTheme || null, + illustrationStyle: payload.illustrationStyle || null, }); }); diff --git a/server/src/routes/generate.ts b/server/src/routes/generate.ts index 3d8ed5d..78bf868 100644 --- a/server/src/routes/generate.ts +++ b/server/src/routes/generate.ts @@ -9,6 +9,7 @@ import { getActiveJobs, GenerationJob, } from "../services/generation"; +import { resolveIllustrationStyle } from "../services/openrouter"; const router = Router(); @@ -57,7 +58,7 @@ router.post( // POST /api/generate/book — Start a new book generation job router.post("/book", (req: Request, res: Response) => { - const { description, pageCount, characterIds, locationIds, elementPhotoPaths, bookId, storyModel, illustrationModel, coverModel, generateAudio, theme, customTheme } = req.body; + const { description, pageCount, characterIds, locationIds, elementPhotoPaths, bookId, storyModel, illustrationModel, coverModel, generateAudio, theme, customTheme, illustrationStyle } = req.body; // Validation if (!description || typeof description !== "string") { @@ -77,6 +78,13 @@ router.post("/book", (req: Request, res: Response) => { res.status(400).json({ error: "locationIds must be an array" }); return; } + let sanitizedIllustrationStyle: string; + try { + sanitizedIllustrationStyle = resolveIllustrationStyle(illustrationStyle); + } catch { + res.status(400).json({ error: "Invalid illustrationStyle" }); + return; + } // Validate and sanitize element photo paths const ELEMENT_PATH_RE = /^elements\/[a-f0-9-]+\.\w+$/; @@ -173,6 +181,7 @@ router.post("/book", (req: Request, res: Response) => { elementPhotoPaths: sanitizedElementPaths, bookId: resolvedBookId, generateAudio: generateAudio !== false, + illustrationStyle: sanitizedIllustrationStyle, ...(storyModel && { storyModel }), ...(illustrationModel && { illustrationModel }), ...(coverModel && { coverModel }), diff --git a/server/src/services/generation.ts b/server/src/services/generation.ts index a265f90..f3a0ea4 100644 --- a/server/src/services/generation.ts +++ b/server/src/services/generation.ts @@ -522,6 +522,7 @@ async function executeGenerateBook(job: GenerationJob): Promise { const coverModel: string | undefined = payload.coverModel; const theme: string | undefined = payload.theme; const customTheme: string | undefined = payload.customTheme; + const illustrationStyle: string | undefined = payload.illustrationStyle; const uploadsDir = getUploadsDir(); const illustrationsDir = path.join(uploadsDir, "illustrations"); const coversDir = path.join(uploadsDir, "covers"); @@ -657,6 +658,7 @@ async function executeGenerateBook(job: GenerationJob): Promise { illustrationModel, visualDirections[i] || undefined, elementPhotoPaths || [], + illustrationStyle, { bookId, stepType: "illustration", @@ -704,6 +706,7 @@ async function executeGenerateBook(job: GenerationJob): Promise { firstImagePath, coverPath, coverModel, + illustrationStyle, { bookId, stepType: "cover", @@ -854,6 +857,7 @@ async function executeRegenerateIllustrations( undefined, undefined, undefined, + undefined, { bookId, stepType: "illustration", @@ -961,10 +965,17 @@ async function executeRegenerateCovers(job: GenerationJob): Promise { } const coverPath = path.join(coversDir, `${book.id}.png`); - const coverResult = await generateCover(book.title, firstPageImagePath, coverPath, undefined, { - bookId: book.id, - stepType: "cover", - }); + const coverResult = await generateCover( + book.title, + firstPageImagePath, + coverPath, + undefined, + undefined, + { + bookId: book.id, + stepType: "cover", + } + ); const success = coverResult.data; saveGenerationLog(coverResult, { diff --git a/server/src/services/openrouter.ts b/server/src/services/openrouter.ts index d9a9883..751fd0b 100644 --- a/server/src/services/openrouter.ts +++ b/server/src/services/openrouter.ts @@ -11,6 +11,81 @@ export const DEFAULT_STORY_MODEL = "anthropic/claude-sonnet-4.6"; export const DEFAULT_ILLUSTRATION_MODEL = "google/gemini-3.1-flash-image-preview"; export const DEFAULT_COVER_MODEL = "bytedance-seed/seedream-4.5"; +export const DEFAULT_ILLUSTRATION_STYLE = "bold-storybook-ink"; + +export const ILLUSTRATION_STYLES = [ + { + id: "bold-storybook-ink", + label: "Bold Storybook Ink", + prompt: "Sharp pen and ink illustration with bold outlines, simple shapes, and a bright picture-book look.", + }, + { + id: "soft-watercolor", + label: "Soft Watercolor", + prompt: "Gentle watercolor washes with soft edges, warm light, airy backgrounds, and a cozy picture-book feel.", + }, + { + id: "gouache-picture-book", + label: "Gouache Picture Book", + prompt: "Opaque painted gouache with rich matte colors, visible brush texture, and playful simplified forms.", + }, + { + id: "paper-cutout-collage", + label: "Paper Cutout Collage", + prompt: "Layered cut-paper collage with handmade paper texture, clear silhouettes, and cheerful playful composition.", + }, + { + id: "colored-pencil-sketch", + label: "Colored Pencil Sketch", + prompt: "Colored pencil and crayon texture with soft hand-drawn lines and warm imperfect childlike charm.", + }, + { + id: "modern-flat-shapes", + label: "Modern Flat Shapes", + prompt: "Clean geometric shapes, minimal detail, bold color blocks, and high readability for small screens.", + }, + { + id: "vintage-print", + label: "Vintage Print", + prompt: "Screen-print inspired texture with a limited palette, slightly offset ink, and nostalgic storybook warmth.", + }, + { + id: "cozy-comic", + label: "Cozy Comic", + prompt: "Expressive cartoon characters with clear facial emotions, bold outlines, and simple cinematic scenes.", + }, +] as const; + +type IllustrationStyleId = (typeof ILLUSTRATION_STYLES)[number]["id"]; + +const ILLUSTRATION_STYLE_IDS = new Set( + ILLUSTRATION_STYLES.map((style) => style.id) +); + +export function resolveIllustrationStyle(styleId: unknown): IllustrationStyleId { + if (styleId == null || styleId === "") { + return DEFAULT_ILLUSTRATION_STYLE; + } + + if (styleId === "surprise-me") { + const index = Math.floor(Math.random() * ILLUSTRATION_STYLES.length); + return ILLUSTRATION_STYLES[index].id; + } + + if (typeof styleId === "string" && ILLUSTRATION_STYLE_IDS.has(styleId)) { + return styleId as IllustrationStyleId; + } + + throw new Error("Invalid illustration style"); +} + +function getIllustrationStyle(styleId: string | undefined) { + return ( + ILLUSTRATION_STYLES.find((style) => style.id === styleId) ?? + ILLUSTRATION_STYLES[0] + ); +} + export interface CharacterRef { name: string; description: string; @@ -793,9 +868,11 @@ export async function generateIllustration( model?: string, visualDirection?: string, elementPhotoPaths?: string[], + illustrationStyle?: string, trace?: TraceMetadata ): Promise> { const useModel = model || DEFAULT_ILLUSTRATION_MODEL; + const style = getIllustrationStyle(illustrationStyle); const uploadsDir = getUploadsDir(); const startTime = Date.now(); let numImagesAttached = 0; @@ -880,8 +957,8 @@ export async function generateIllustration( prompt += `${visualDirection}\n\n`; } - prompt += `Style: Sharp pen and ink illustration with bold lines. `; - prompt += `Use a limited palette of 6 highly saturated colors suitable for a color e-ink display. `; + prompt += `Illustration style: ${style.label}. ${style.prompt} `; + prompt += `Use a limited, high-contrast palette suitable for a color e-ink display. `; prompt += `The illustration should be simple, clear, and appealing to young children.\n\n`; prompt += `IMPORTANT: The image must be horizontal/landscape orientation.\n\n`; prompt += `IMPORTANT: Do NOT include any text, words, letters, numbers, captions, titles, labels, or writing of any kind in the image. The image must contain only visual artwork with zero text.`; @@ -1039,15 +1116,18 @@ export async function generateCover( firstPageImagePath: string, outputPath: string, model?: string, + illustrationStyle?: string, trace?: TraceMetadata ): Promise> { const useModel = model || DEFAULT_COVER_MODEL; + const style = getIllustrationStyle(illustrationStyle); const uploadsDir = getUploadsDir(); const startTime = Date.now(); const prompt = `Use this image as the basis for generating a book cover ` + `with the title "${title}". You have artistic license to be ` + `creative with typography but keep the same basic content concepts. ` + + `Match this book's illustration style: ${style.label}. ${style.prompt} ` + `Ratio should be 3:2 portrait orientation.`; let numImagesAttached = 0; diff --git a/web/src/api/client.ts b/web/src/api/client.ts index d2bd2fc..af2ef67 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -163,6 +163,7 @@ export const startGeneration = (data: { generateAudio?: boolean; theme?: string; customTheme?: string; + illustrationStyle?: string; }) => request<{ jobId: string; bookId: string }>("/generate/book", { method: "POST", @@ -210,6 +211,7 @@ export interface GenerationParams { generateAudio?: boolean; theme?: string; customTheme?: string; + illustrationStyle?: string | null; } export const getBookGenerationParams = (bookId: string) => diff --git a/web/src/index.css b/web/src/index.css index 6be8689..ef0dee4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -186,6 +186,24 @@ body { font-size: 0.9rem; } +.illustration-style-options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); + gap: 8px; +} +.illustration-style-btn { + display: flex; flex-direction: column; gap: 3px; align-items: flex-start; + padding: 10px 12px; border: 2px solid var(--border); border-radius: var(--radius); + background: var(--surface); cursor: pointer; text-align: left; transition: all 0.15s; +} +.illustration-style-btn:hover { border-color: var(--primary); } +.illustration-style-btn.active { + background: var(--primary-light); border-color: var(--primary); color: var(--primary); +} +.illustration-style-label { font-size: 0.9rem; font-weight: 600; } +.illustration-style-description { font-size: 0.75rem; color: var(--text-muted); line-height: 1.35; } +.illustration-style-btn.active .illustration-style-description { color: var(--primary); } + .character-picker { display: flex; flex-direction: column; gap: 12px; } .character-group-label { font-size: 0.8rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } .character-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 4px; } diff --git a/web/src/pages/CreateBook.tsx b/web/src/pages/CreateBook.tsx index ad4b948..4ceb6b1 100644 --- a/web/src/pages/CreateBook.tsx +++ b/web/src/pages/CreateBook.tsx @@ -39,6 +39,56 @@ const THEME_OPTIONS = [ { id: "custom", label: "Custom theme…" }, ]; +const DEFAULT_ILLUSTRATION_STYLE = "bold-storybook-ink"; + +const ILLUSTRATION_STYLE_OPTIONS = [ + { + id: DEFAULT_ILLUSTRATION_STYLE, + label: "Bold Storybook Ink", + description: "Bold outlines, simple shapes, bright picture-book color.", + }, + { + id: "soft-watercolor", + label: "Soft Watercolor", + description: "Gentle washes, soft edges, cozy warm light.", + }, + { + id: "gouache-picture-book", + label: "Gouache Picture Book", + description: "Matte paint, rich color, playful brush texture.", + }, + { + id: "paper-cutout-collage", + label: "Paper Cutout Collage", + description: "Layered paper shapes with handmade texture.", + }, + { + id: "colored-pencil-sketch", + label: "Colored Pencil Sketch", + description: "Crayon texture, hand-drawn lines, childlike charm.", + }, + { + id: "modern-flat-shapes", + label: "Modern Flat Shapes", + description: "Clean geometric forms and bold readable color blocks.", + }, + { + id: "vintage-print", + label: "Vintage Print", + description: "Limited palette, print texture, nostalgic warmth.", + }, + { + id: "cozy-comic", + label: "Cozy Comic", + description: "Expressive cartoon faces and clear cinematic scenes.", + }, + { + id: "surprise-me", + label: "Surprise me", + description: "Pick one of these styles for this book.", + }, +]; + const LEADING_ARTICLES = new Set(["a", "an", "the"]); type NamedEntity = { @@ -157,6 +207,7 @@ export function CreateBook() { const [elementPreviews, setElementPreviews] = useState([]); const [theme, setTheme] = useState("none"); const [customTheme, setCustomTheme] = useState(""); + const [illustrationStyle, setIllustrationStyle] = useState(DEFAULT_ILLUSTRATION_STYLE); const [loading, setLoading] = useState(true); const [variationTitle, setVariationTitle] = useState(null); @@ -217,6 +268,7 @@ export function CreateBook() { if (params.generateAudio !== undefined) setGenerateAudio(params.generateAudio); if (params.theme) setTheme(params.theme); if (params.customTheme) setCustomTheme(params.customTheme); + if (params.illustrationStyle) setIllustrationStyle(params.illustrationStyle); }).catch(console.error); }, [fromBookId]); @@ -380,6 +432,7 @@ export function CreateBook() { generateAudio, theme: theme !== "none" ? theme : undefined, customTheme: theme === "custom" ? customTheme.trim() : undefined, + illustrationStyle, ...modelOverrides, }); startPolling(result.jobId, result.bookId); @@ -450,6 +503,23 @@ export function CreateBook() { )} +
+ +
+ {ILLUSTRATION_STYLE_OPTIONS.map((style) => ( + + ))} +
+
+