From cb80cdfc3588a3c2f77587cf0fbbd1e52d7dab6b Mon Sep 17 00:00:00 2001 From: Dan Date: Mon, 29 Jun 2026 14:10:51 +0000 Subject: [PATCH] Add created-lesson data layer, generation pipeline, lesson builder --- src/lib/createdLessons.ts | 188 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/lib/createdLessons.ts diff --git a/src/lib/createdLessons.ts b/src/lib/createdLessons.ts new file mode 100644 index 0000000..fc1ea59 --- /dev/null +++ b/src/lib/createdLessons.ts @@ -0,0 +1,188 @@ +import "server-only"; +import { getDb } from "./db"; +import { svgToRevealSteps } from "./vectorize"; +import type { Lesson } from "./curriculum"; + +/** + * Data + lifecycle for AI-created lessons. A row is generated asynchronously (ComfyUI), vectorized + * (potrace), and then rendered through the normal runners by building Lesson objects on the fly. + */ + +export type CreatedStatus = "pending" | "generating" | "needs_review" | "ready" | "failed" | "blocked"; + +export interface CreatedLessonRow { + id: number; + user_id: number | null; + level: string; + subject: string; + slug: string; + status: CreatedStatus; + template_svg: string | null; + image: string | null; + prompt: string; + moderation: string | null; + emoji: string; + error: string | null; + promoted: number; + created_at: string; + updated_at: string; +} + +function kebab(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); +} + +/** Insert a created-lesson row and return it (slug includes the row id for uniqueness). */ +export function createCreatedLesson(opts: { + userId: number; + level: string; + subject: string; + prompt: string; + moderation: string; + status: CreatedStatus; + emoji?: string; +}): CreatedLessonRow { + const db = getDb(); + const info = db + .prepare( + `INSERT INTO created_lessons (user_id, level, subject, slug, status, prompt, moderation, emoji) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run(opts.userId, opts.level, opts.subject, `pending-${Date.now()}`, opts.status, opts.prompt, opts.moderation, opts.emoji || "🎨"); + const id = Number(info.lastInsertRowid); + const slug = `created-${id}-${kebab(opts.subject)}`; + db.prepare("UPDATE created_lessons SET slug = ? WHERE id = ?").run(slug, id); + return getCreatedById(id)!; +} + +export function getCreatedById(id: number): CreatedLessonRow | undefined { + return getDb().prepare("SELECT * FROM created_lessons WHERE id = ?").get(id) as CreatedLessonRow | undefined; +} + +/** Look up by the base slug (e.g. 'created-42-zebra'), ignoring any '-trace'/'-color' phase suffix. */ +export function getCreatedBySlug(baseSlug: string): CreatedLessonRow | undefined { + return getDb().prepare("SELECT * FROM created_lessons WHERE slug = ?").get(baseSlug) as CreatedLessonRow | undefined; +} + +export function listCreatedForUser(userId: number, level: string): CreatedLessonRow[] { + return getDb() + .prepare("SELECT * FROM created_lessons WHERE user_id = ? AND level = ? ORDER BY created_at DESC") + .all(userId, level) as CreatedLessonRow[]; +} + +/** Promoted (global) creations, visible to everyone on a level. */ +export function listPromoted(level: string): CreatedLessonRow[] { + return getDb() + .prepare("SELECT * FROM created_lessons WHERE promoted = 1 AND status = 'ready' AND level = ? ORDER BY created_at DESC") + .all(level) as CreatedLessonRow[]; +} + +/** Admin queue: things awaiting review. */ +export function listNeedsReview(): CreatedLessonRow[] { + return getDb() + .prepare("SELECT * FROM created_lessons WHERE status = 'needs_review' ORDER BY created_at") + .all() as CreatedLessonRow[]; +} + +/** Admin queue: ready creations not yet promoted to the global curriculum. */ +export function listReadyUnpromoted(): CreatedLessonRow[] { + return getDb() + .prepare("SELECT * FROM created_lessons WHERE status = 'ready' AND promoted = 0 ORDER BY created_at DESC") + .all() as CreatedLessonRow[]; +} + +export function updateCreatedStatus(id: number, status: CreatedStatus, error?: string) { + getDb() + .prepare("UPDATE created_lessons SET status = ?, error = ?, updated_at = datetime('now') WHERE id = ?") + .run(status, error ?? null, id); +} + +function setReady(id: number, templateSvg: string, image: string) { + getDb() + .prepare("UPDATE created_lessons SET status = 'ready', template_svg = ?, image = ?, error = NULL, updated_at = datetime('now') WHERE id = ?") + .run(templateSvg, image, id); +} + +export function promoteCreated(id: number) { + getDb().prepare("UPDATE created_lessons SET promoted = 1, updated_at = datetime('now') WHERE id = ?").run(id); +} + +/** + * Run generation for a row: ComfyUI -> vectorize -> ready (or failed). Fire-and-forget from the API + * route; safe on a long-lived self-hosted node. Heavy deps are imported lazily so the data layer stays + * light for normal page renders. + */ +export async function processCreation(id: number): Promise { + const row = getCreatedById(id); + if (!row) return; + try { + updateCreatedStatus(id, "generating"); + const { generateLineArt } = await import("./comfyui"); + const { pngToTemplateSvg } = await import("./vectorize"); + const png = await generateLineArt(row.prompt); + const templateSvg = await pngToTemplateSvg(png); + setReady(id, templateSvg, `data:image/png;base64,${png.toString("base64")}`); + } catch (e) { + updateCreatedStatus(id, "failed", e instanceof Error ? e.message : String(e)); + } +} + +/** Build the playable lessons (Trace + Color) from a ready row. */ +export function buildCreatedLessons(row: CreatedLessonRow): Lesson[] { + const base = 900000 + row.id * 10; + const tpl = row.template_svg || ""; + const lower = row.subject.toLowerCase(); + const steps = svgToRevealSteps(tpl).map((els, i) => ({ + n: i + 1, + title: `Lines ${i + 1}`, + instruction: "Trace the lines, one part at a time.", + tip: "", + lines: els, + })); + const common = { + level: row.level, + emoji: row.emoji, + subjectKey: row.slug, + subjectName: row.subject, + subjectEmoji: row.emoji, + order: 0, + badgeName: `${row.subject} Artist`, + }; + const trace: Lesson = { + ...common, + sublevel: base + 1, + slug: `${row.slug}-trace`, + title: `${row.subject} · Trace`, + subject: `${lower} trace`, + intro: `Watch your ${lower} appear, then trace it!`, + badgeKey: "", + phase: "outline", + baseSvg: "", + steps, + }; + const color: Lesson = { + ...common, + sublevel: base + 2, + slug: `${row.slug}-color`, + title: `${row.subject} · Color`, + subject: `color the ${lower}`, + intro: `Bring your ${lower} to life! Color it in.`, + badgeKey: "", + phase: "color", + baseSvg: tpl, + steps: [], + }; + return [trace, color]; +} + +/** Resolve a lesson-page slug (…-trace / …-color) to its created lesson + its phase siblings. */ +export function resolveCreatedLesson(slug: string): { lessons: Lesson[]; index: number; row: CreatedLessonRow } | null { + const m = slug.match(/^(.*)-(trace|color)$/); + if (!m) return null; + const row = getCreatedBySlug(m[1]); + if (!row || row.status !== "ready") return null; + const lessons = buildCreatedLessons(row); + const index = lessons.findIndex((l) => l.slug === slug); + if (index < 0) return null; + return { lessons, index, row }; +}