Add created-lesson data layer, generation pipeline, lesson builder
This commit is contained in:
1 parent
ddb5362050
commit
cb80cdfc35
1 file changed
+188
@@ -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<void> {
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user