Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions server/src/routes/books.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});

Expand Down
11 changes: 10 additions & 1 deletion server/src/routes/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getActiveJobs,
GenerationJob,
} from "../services/generation";
import { resolveIllustrationStyle } from "../services/openrouter";

const router = Router();

Expand Down Expand Up @@ -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") {
Expand All @@ -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+$/;
Expand Down Expand Up @@ -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 }),
Expand Down
19 changes: 15 additions & 4 deletions server/src/services/generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,7 @@ async function executeGenerateBook(job: GenerationJob): Promise<void> {
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");
Expand Down Expand Up @@ -657,6 +658,7 @@ async function executeGenerateBook(job: GenerationJob): Promise<void> {
illustrationModel,
visualDirections[i] || undefined,
elementPhotoPaths || [],
illustrationStyle,
{
bookId,
stepType: "illustration",
Expand Down Expand Up @@ -704,6 +706,7 @@ async function executeGenerateBook(job: GenerationJob): Promise<void> {
firstImagePath,
coverPath,
coverModel,
illustrationStyle,
{
bookId,
stepType: "cover",
Expand Down Expand Up @@ -854,6 +857,7 @@ async function executeRegenerateIllustrations(
undefined,
undefined,
undefined,
undefined,
{
bookId,
stepType: "illustration",
Expand Down Expand Up @@ -961,10 +965,17 @@ async function executeRegenerateCovers(job: GenerationJob): Promise<void> {
}

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, {
Expand Down
84 changes: 82 additions & 2 deletions server/src/services/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(
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;
Expand Down Expand Up @@ -793,9 +868,11 @@ export async function generateIllustration(
model?: string,
visualDirection?: string,
elementPhotoPaths?: string[],
illustrationStyle?: string,
trace?: TraceMetadata
): Promise<GenerationResult<boolean>> {
const useModel = model || DEFAULT_ILLUSTRATION_MODEL;
const style = getIllustrationStyle(illustrationStyle);
const uploadsDir = getUploadsDir();
const startTime = Date.now();
let numImagesAttached = 0;
Expand Down Expand Up @@ -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.`;
Expand Down Expand Up @@ -1039,15 +1116,18 @@ export async function generateCover(
firstPageImagePath: string,
outputPath: string,
model?: string,
illustrationStyle?: string,
trace?: TraceMetadata
): Promise<GenerationResult<boolean>> {
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;
Expand Down
2 changes: 2 additions & 0 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -210,6 +211,7 @@ export interface GenerationParams {
generateAudio?: boolean;
theme?: string;
customTheme?: string;
illustrationStyle?: string | null;
}

export const getBookGenerationParams = (bookId: string) =>
Expand Down
18 changes: 18 additions & 0 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
70 changes: 70 additions & 0 deletions web/src/pages/CreateBook.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -157,6 +207,7 @@ export function CreateBook() {
const [elementPreviews, setElementPreviews] = useState<string[]>([]);
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<string | null>(null);

Expand Down Expand Up @@ -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]);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -450,6 +503,23 @@ export function CreateBook() {
)}
</div>

<div className="form-group">
<label>Illustration style</label>
<div className="illustration-style-options">
{ILLUSTRATION_STYLE_OPTIONS.map((style) => (
<button
key={style.id}
className={`illustration-style-btn ${illustrationStyle === style.id ? "active" : ""}`}
onClick={() => setIllustrationStyle(style.id)}
type="button"
>
<span className="illustration-style-label">{style.label}</span>
<span className="illustration-style-description">{style.description}</span>
</button>
))}
</div>
</div>

<div className="form-group">
<label>Number of pages</label>
<div className="page-count-options">
Expand Down
Loading