Skip to main content

illustrated-explainer-spec

Spec for building an infinite drill-down AI illustrated explainer web app where users type a topic and click to drill into generated watercolor-style images.

소스 정보

저장소
reason-machines/trending-skills
최근 소스 활동
2026년 4월 29일 01:43
감지된 SKILL.md 언어
영어
스타
83
포크
15

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
name
illustrated-explainer-spec
description
Spec for building an infinite drill-down AI illustrated explainer web app where users type a topic and click to drill into generated watercolor-style images.
triggers
["implement the illustrated explainer spec","build a drill-down explainer app","create an infinite drill-down image generator","set up the illustrated explainer project","implement drill-down watercolor explainer","build AI image drill-down app","implement the flipbook explainer spec","create click-to-drill image explainer"]
# Illustrated Explainer Spec Implementation Guide > Skill by [ara.so](https://ara.so) — Daily 2026 Skills collection. ## What This Project Is A spec (not a library) for building a locally-run single-page web app where: 1. User types a topic → AI generates a 16:9 watercolor-style illustrated explainer page 2. User clicks anywhere on the image → AI generates a "drill-into" next page for that spot 3. This repeats infinitely, preserving painting style across all pages 4. Content-addressed caching means identical queries/clicks never re-generate The spec is **stack-agnostic** — you choose the framework, image model API, and language. This skill shows you how to implement it end-to-end. --- ## Architecture Overview ``` Browser (thin client) └── POST /api/page ──► Server ├── hash → check disk cache ├── composite red marker onto parent image ├── call image model (text + optional image) └── write PNG → return page object ``` ### Page Object Shape ```typescript interface Page { id: string; // deterministic hash imageUrl: string; // e.g. "/generated/abc123.png" parentId: string | null; parentClick: { x: number; y: number } | null; // normalized 0–1 initialQuery: string | null; // only on page 1 } ``` --- ## Recommended Stack (Node.js + Google Gemini) ```bash mkdir explainer && cd explainer npm init -y npm install express sharp crypto @google/generative-ai cors dotenv ``` ``` .env GEMINI_API_KEY=your_key_here CACHE_VERSION=v1 PORT=3000 ``` --- ## Server Implementation ### `server.js` — Full Reference Implementation ```javascript import express from 'express'; import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import sharp from 'sharp'; import { GoogleGenerativeAI } from '@google/generative-ai'; import 'dotenv/config'; const app = express(); app.use(express.json()); app.use(express.static('public')); app.use('/generated', express.static('public/generated')); const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY); const VERSION = process.env.CACHE_VERSION || 'v1'; const GENERATED_DIR = path.join('public', 'generated'); fs.mkdirSync(GENERATED_DIR, { recursive: true }); // ── Content-addressed IDs ────────────────────────────────────────────────── function hashFirstPage(query) { const normalized = query.trim().replace(/\s+/g, ' ').toLowerCase(); return crypto .createHash('sha256') .update(`first${VERSION}${normalized}`) .digest('hex') .slice(0, 32); } function hashChildPage(parentId, x, y) { const rx = Math.round(x * 100) / 100; const ry = Math.round(y * 100) / 100; return crypto .createHash('sha256') .update(`child${VERSION}${parentId}${rx}${ry}`) .digest('hex') .slice(0, 32); } // ── Style description (single source of truth) ──────────────────────────── const STYLE_DESCRIPTION = `Painting style (must remain consistent across every page): - Light warm paper background with generous margins - Clean, even dark gray or black ink outlines, consistent thin line weight - Soft watercolor washes, pale palette: ivory, pale green, pale blue, light gray, with restrained warm accents - A large serif title printed at the top center of the image - Calm, well-composed scene with breathing room Strict exclusions: - No decorative borders, seals, parchment aging, ornate fonts, or vintage texture - No 3D render, photorealism, neon, dark themes, or modern app UI cards - No dense paragraphs of text, watermarks, or tiny unreadable labels - No tourist map roads, landmarks, transit, or "traveler-guide" framing`; function firstPagePrompt(query) { return `${STYLE_DESCRIPTION} Subject: ${query} Compose a single 16:9 illustrated explainer page about the subject above. Let the scene's content (objects, layout, metaphor) be whatever best explains the subject — cross-section, exploded view, timeline, anatomy, flow, comparison, or scene — chosen to fit this specific topic. Output a single PNG image, 16:9. Print the title clearly inside the image.`; } const CHILD_PAGE_PROMPT = `${STYLE_DESCRIPTION} You are continuing an illustrated explainer book. The provided image is the previous page. A red circle marks the area the reader pointed at. Generate the next page: a single 16:9 image that goes deeper into whatever the red circle is on — zoom in, expand its inner structure, or show its mechanism. Critical: match the painting style of the provided image exactly — same line weight, same paper tone, same pastel palette, same title typography. The two pages must feel like consecutive spreads in the same hand-drawn book. Do NOT include the red circle or any cursor mark in the output. Output a single PNG image, 16:9.`; // ── Red marker compositing ───────────────────────────────────────────────── async function compositeRedMarker(imagePath, nx, ny) { const img = sharp(imagePath); const { width, height } = await img.metadata(); const cx = Math.round(nx * width); const cy = Math.round(ny * height); const radius = Math.round(width * 0.04); // Build SVG marker: outer ring + inner dot const svg = `<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg"> <circle cx="${cx}" cy="${cy}" r="${radius}" fill="rgba(220,30,30,0.25)" stroke="red" stroke-width="${Math.max(3, radius * 0.15)}"/> <circle cx="${cx}" cy="${cy}" r="${Math.round(radius * 0.3)}" fill="red" stroke="white" stroke-width="2"/> </svg>`; return img .composite([{ input: Buffer.from(svg), blend: 'over' }]) .png() .toBuffer(); } // ── Image model call ─────────────────────────────────────────────────────── async function callImageModel(prompt, referenceImageBuffer = null) { const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash-preview-image-generation' }); const parts = [{ text: prompt }]; if (referenceImageBuffer) { parts.push({ inlineData: { mimeType: 'image/png', data: referenceImageBuffer.toString('base64'), }, }); } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 120_000); try { const result = await model.generateContent({ contents: [{ role: 'user', parts }], generationConfig: { responseModalities: ['IMAGE'] }, }); clearTimeout(timeout); const candidates = result.response.candidates ?? []; for (const candidate of candidates) { for (const part of candidate.content?.parts ?? []) { if (part.inlineData?.mimeType?.startsWith('image/')) { return Buffer.from(part.inlineData.data, 'base64'); } } } throw new Error('No inline image in model response'); } catch (err) { clearTimeout(timeout); throw err; } } // ── Generation queue (serialize requests) ───────────────────────────────── let queue = Promise.resolve(); function enqueue(fn) { queue = queue.then(fn, fn); // always advance queue even on error return queue; } // ── Core generation logic ────────────────────────────────────────────────── async function generatePage(id, prompt, referenceImageBuffer) { const outPath = path.join(GENERATED_DIR, `${id}.png`); // Cache hit if (fs.existsSync(outPath) && fs.statSync(outPath).size > 0) { return `/generated/${id}.png`; } const imageBytes = await callImageModel(prompt, referenceImageBuffer); fs.writeFileSync(outPath, imageBytes); return `/generated/${id}.png`; } // ── /api/page endpoint ──────────────────────────────────────────────────── const ID_REGEX = /^[0-9a-f]{32}$/; app.post('/api/page', async (req, res) => { const { query, parentId, parentClick } = req.body; // Validate if (query !== undefined) { if (typeof query !== 'string' || query.trim().length < 1 || query.length > 300) { return res.status(400).json({ error: 'query must be 1–300 chars' }); } } else { if (!ID_REGEX.test(parentId)) { return res.status(400).json({ error: 'invalid parentId' }); } const { x, y } = parentClick ?? {}; if ( typeof x !== 'number' || typeof y !== 'number' || !isFinite(x) || !isFinite(y) || x < 0 || x > 1 || y < 0 || y > 1 ) { return res.status(400).json({ error: 'x and y must be finite floats in [0, 1]' }); } } try { const page = await enqueue(async () => { if (query !== undefined) { // First page const trimmed = query.trim(); const id = hashFirstPage(trimmed); const imageUrl = await generatePage(id, firstPagePrompt(trimmed), null); return { id, imageUrl, parentId: null, parentClick: null, initialQuery: trimmed }; } else { // Child page const id = hashChildPage(parentId, parentClick.x, parentClick.y); const parentPath = path.join(GENERATED_DIR, `${parentId}.png`); if (!fs.existsSync(parentPath)) { throw new Error('Parent image not found'); } const markedBuffer = await compositeRedMarker(parentPath, parentClick.x, parentClick.y); const imageUrl = await generatePage(id, CHILD_PAGE_PROMPT, markedBuffer); return { id, imageUrl, parentId, parentClick, initialQuery: null }; } }); res.json({ page }); } catch (err) { console.error(err); res.status(500).json({ error: 'Generation failed, try clicking elsewhere.' }); } }); app.listen(process.env.PORT || 3000, () => { console.log(`Explainer running on http://localhost:${process.env.PORT || 3000}`); }); ``` --- ## Client Implementation ### `public/index.html` ```html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Drill-Down Explainer</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: system-ui, sans-serif; background: #1a1a1a; color: #eee; display: flex; flex-direction: column; height: 100vh; } #topbar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #111; flex-shrink: 0; } #appname { font-weight: bold; font-size: 1.1rem; } #counter { font-size: 0.85rem; color: #aaa; margin-right: auto; } #topic-input { flex: 1; padding: 6px 10px; border-radius: 6px; border: none; font-size: 1rem; max-width: 420px; } button { padding: 6px 14px; border-radius: 6px; border: none; cursor: pointer; font-size: 0.9rem; background: #444; color: #eee; } button:disabled { opacity: 0.4; cursor: not-allowed; } #generate-btn { background: #4a7eff; color: #fff; } #canvas-area { flex: 1; position: relative; display: flex; align-items: center; justify-content: center; overflow: hidden; } #page-img { max-width: 100%; max-height: 100%; cursor: crosshair; display: block; } #loading-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; font-size: 1.2rem; display: none; } #error-banner { background: #b00; color: #fff; padding: 6px 16px; font-size: 0.9rem; display: none; flex-shrink: 0; } #thumbstrip { display: flex; gap: 8px; padding: 8px 16px; background: #111; overflow-x: auto; flex-shrink: 0; min-height: 72px; } .thumb { width: 96px; height: 54px; object-fit: cover; border-radius: 4px; cursor: pointer; border: 2px solid transparent; flex-shrink: 0; } .thumb.active { border-color: #4a7eff; } .ripple { position: absolute; border-radius: 50%; background: rgba(255,80,80,0.5); transform: scale(0); animation: ripple 0.6s ease-out forwards; pointer-events: none; width: 40px; height: 40px; margin: -20px; } @keyframes ripple { to { transform: scale(3); opacity: 0; } } </style> </head> <body> <div id="topbar"> <span id="appname">🔍 Explainer</span> <span id="counter"></span> <input id="topic-input" placeholder="Type a topic…" maxlength="300" /> <button id="generate-btn">Generate</button> <button id="back-btn" disabled>← Back</button> <button id="reset-btn" disabled>Reset</button> </div>
GitHub에서 보기
이 SKILL.md는 매우 커서 SkillsMP가 여기에는 첫 섹션만 미리 보여줍니다. GitHub에서 보기