All story content is pre-coded here: 6 chapter titles, 18 story text snippets, and 18 image prompts.
No AI is used for text generation. The backend only calls Gemini for the illustration.
Every page N option was written to transition naturally into every page N+1 option.
STORY = {
"id": "cinderella",
"title": "Cinderella",
"total_pages": 6,
"cover_prompt": "Children's coloring book cover illustration. Cinderella stands at "
"the top of a grand castle staircase in a flowing gown...",
"pages": [
{
"page": 1,
"chapter_title": "The Morning Before the Ball",
"options": [
{
"id": "a",
"preview": "Cinderella hums while sweeping, holding onto a quiet hope",
"story_text": "Cinderella hummed softly as she swept the cold stone floors...",
"image_prompt": "Children's coloring book page. Cinderella sweeping stone "
"castle floors with a long broom, wearing a simple dress..."
},
{ "id": "b", "preview": "...", "story_text": "...", "image_prompt": "..." },
{ "id": "c", "preview": "...", "story_text": "...", "image_prompt": "..." },
]
},
# pages 2–6 follow the same structure
]
}
🌐 main.py — Three Endpoints
The backend is intentionally minimal. All story logic is in story_library.py. The API
serves story data, generates/caches illustrations, and handles image edits.
@app.get("/story")
async def get_story():
"""Return all 6 pages + 3 options each (no images) — instant, no AI."""
return { "id": ..., "title": ..., "pages": [...] }
@app.get("/story/cover")
async def get_cover():
"""Generate cover illustration once, then serve from cache forever."""
if cache.has("cover_image"):
return {"image": cache.get("cover_image")}
image = await image_service.generate_single(STORY["cover_prompt"])
cache.set("cover_image", image)
return {"image": image}
@app.post("/generate/page-image")
async def generate_page_image(req: PageImageRequest):
"""Generate illustration for page_number + option_id. Cache result."""
cache_key = f"page_{req.page_number}_{req.option_id}"
if cache.has(cache_key):
return {"image": cache.get(cache_key)}
option = find_option(req.page_number, req.option_id)
image = await image_service.generate_single(option["image_prompt"])
cache.set(cache_key, image)
return {"image": image}
🖼️ image_service.py — Gemini Image Generation
Uses the google-genai SDK with response_modalities=["IMAGE"] to request image output from Gemini.
The generate_single method prepends a coloring-book style prefix to every prompt.
A fallback SVG placeholder is returned if the API call fails.
COLORING_BOOK_STYLE = (
"children's coloring book illustration, thick clean black outlines, "
"white background, no color fill, simple friendly style, "
"large expressive characters, suitable for ages 5-10, child-safe"
)
async def generate_single(self, prompt: str) -> str:
full_prompt = f"{COLORING_BOOK_STYLE}. Scene: {prompt}. "
"Draw only clean black outlines on pure white."
response = await loop.run_in_executor(
None,
lambda: self.client.models.generate_content(
model=IMAGE_MODEL,
contents=full_prompt,
config=types.GenerateContentConfig(
response_modalities=["IMAGE"]
)
)
)
for part in response.candidates[0].content.parts:
if hasattr(part, "inline_data") and part.inline_data:
data = part.inline_data.data
return base64.b64encode(data).decode("utf-8") # return as base64 string
📱 App.tsx — State Machine
Five states govern the entire user flow. The app loads story data and the cover image
in parallel on mount, then steps the user through each page one at a time.
type AppState = 'loading' | 'cover' | 'choosing' | 'generating' | 'coloring'
// On mount: fetch story JSON + cover image in parallel
useEffect(() => {
Promise.all([fetchStory(), fetchCoverImage()])
.then(([story, coverImg]) => {
setStory(story)
setCoverImage(coverImg)
setAppState('cover') // show cover once both are ready
})
}, [])
// User picks an option → generate image → add to completedPages → coloring
const handleOptionSelect = async (optionId: string) => {
setAppState('generating')
const image = await generatePageImage(choosingPage, optionId)
setCompletedPages(prev => [...prev, { page, option_id, story_text, image }])
setAppState('coloring')
}
🎨 FloodFillCanvas.tsx — BFS Coloring
The canvas renders the base64 illustration and runs a BFS flood-fill when the user
clicks. Each click saves a snapshot to an undo stack (max 10 steps). The undo and
clear methods are exposed via a forwarded ref.
// BFS flood-fill with colour tolerance ±20 per channel
function floodFill(ctx, x, y, fillColor) {
const imageData = ctx.getImageData(0, 0, width, height)
const targetColor = getPixel(imageData, x, y)
const stack = [[x, y]]
while (stack.length) {
const [cx, cy] = stack.pop()
if (!withinTolerance(getPixel(imageData, cx, cy), targetColor)) continue
setPixel(imageData, cx, cy, fillColor)
stack.push([cx-1,cy], [cx+1,cy], [cx,cy-1], [cx,cy+1])
}
ctx.putImageData(imageData, 0, 0)
}
// Undo: save canvas state before each fill
const handleClick = (e) => {
undoStack.current.push(ctx.getImageData(0, 0, w, h)) // snapshot
floodFill(ctx, x, y, selectedColor)
}
📄 PDF Export (inline in ColoringBook.tsx)
jsPDF runs entirely in the browser. Each completed page is added as a landscape A4 page
with the illustration filling the top 63% and the story text in a rounded box below.
const pdf = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
completedPages.forEach((p, i) => {
if (i > 0) pdf.addPage()
// Illustration — top 63% of page
pdf.addImage(base64ToDataUrl(image), 'PNG', margin, margin, W - margin*2, H * 0.63)
// Story text box — rounded rect below
pdf.roundedRect(margin, textY, W - margin*2, H - textY - margin, 6, 6, 'F')
pdf.text(p.chapter_title, margin + 6, textY + 8)
pdf.text(pdf.splitTextToSize(p.story_text, W - margin*2 - 12), margin + 6, textY + 16)
})
pdf.save('cinderella-coloring-book.pdf')