Add progress bar for ai gen image (est...) #16

Merged
iamdoubz merged 1 commits from feature_aicreation_countdown into main 2026-06-29 13:10:01 -05:00
5 changed files with 84 additions and 9 deletions
+6 -1
View File
@@ -18,8 +18,12 @@ export default function AdminCreations({ review, ready }: { review: Row[]; ready
const [busy, setBusy] = useState<number | null>(null);
const [lightbox, setLightbox] = useState<string | null>(null);
const [editing, setEditing] = useState<number | null>(null);
const [prog, setProg] = useState<Record<number, { percent: number; etaSeconds: number | null }>>({});
const editVal = useRef("");
const etaText = (secs: number | null) =>
secs === null ? "estimating…" : secs <= 0 ? "almost done…" : secs < 60 ? `about ${secs}s left` : `about ${Math.ceil(secs / 60)} min left`;
// Auto-update: while anything is generating, poll its status and refresh when it changes
// (so a Regenerate's new image appears on screen without a manual reload).
useEffect(() => {
@@ -30,6 +34,7 @@ export default function AdminCreations({ review, ready }: { review: Row[]; ready
try {
const res = await fetch(`/api/create/${r.id}`);
const d = await res.json();
if (d.progress) setProg((prev) => ({ ...prev, [r.id]: { percent: d.progress.percent, etaSeconds: d.progress.etaSeconds } }));
if (d.status && d.status !== r.status) {
router.refresh();
break;
@@ -97,7 +102,7 @@ export default function AdminCreations({ review, ready }: { review: Row[]; ready
</strong>
)}
<div className="muted" style={{ fontSize: "0.8rem", marginTop: 2 }}>
{working ? "🎨 Drawing… (updates automatically)" : r.status === "review" ? "🔎 Awaiting approval" : r.status === "needs_review" ? "⏳ Needs generation" : r.status === "failed" ? `⚠️ Failed: ${r.error || "unknown error"}` : r.status === "ready" ? "✓ Ready" : r.status}
{working ? `🎨 Drawing… ${prog[r.id] ? `${prog[r.id].percent}% · ${etaText(prog[r.id].etaSeconds)}` : "(updates automatically)"}` : r.status === "review" ? "🔎 Awaiting approval" : r.status === "needs_review" ? "⏳ Needs generation" : r.status === "failed" ? `⚠️ Failed: ${r.error || "unknown error"}` : r.status === "ready" ? "✓ Ready" : r.status}
</div>
</div>
<span className="row" style={{ gap: 6, flexWrap: "wrap" }}>
+12 -2
View File
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/session";
import { getCreatedById } from "@/lib/createdLessons";
import { getCreatedById, getCreatedProgress } from "@/lib/createdLessons";
// Poll the status of a creation (owner or admin).
export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
@@ -13,5 +13,15 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
if (row.user_id !== user.id && user.role !== "admin")
return NextResponse.json({ error: "Not authorized." }, { status: 403 });
return NextResponse.json({ id: row.id, slug: row.slug, status: row.status, error: row.error, subject: row.subject });
// Best-effort generation progress + ETA (from ComfyUI's WebSocket step events).
let progress: { value: number; max: number; percent: number; etaSeconds: number | null } | null = null;
const p = getCreatedProgress(row.id);
if (p && p.max > 0) {
const elapsed = Date.now() - p.startedAt;
const perStep = p.value > 0 ? elapsed / p.value : 0;
const etaSeconds = perStep > 0 ? Math.round((Math.max(0, p.max - p.value) * perStep) / 1000) : null;
progress = { value: p.value, max: p.max, percent: Math.round((p.value / p.max) * 100), etaSeconds };
}
return NextResponse.json({ id: row.id, slug: row.slug, status: row.status, error: row.error, subject: row.subject, progress });
}
+22 -2
View File
@@ -10,6 +10,7 @@ export default function CreateForm({ level: initialLevel, levels, enabled }: { l
const [phase, setPhase] = useState<Phase>("idle");
const [msg, setMsg] = useState("");
const [slug, setSlug] = useState("");
const [progress, setProgress] = useState<{ percent: number; etaSeconds: number | null } | null>(null);
const poll = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
useEffect(() => () => clearInterval(poll.current), []);
@@ -50,12 +51,19 @@ export default function CreateForm({ level: initialLevel, levels, enabled }: { l
try {
const r = await fetch(`/api/create/${id}`);
const d = await r.json();
if (d.progress) setProgress({ percent: d.progress.percent, etaSeconds: d.progress.etaSeconds });
if (d.status === "ready") {
clearInterval(poll.current);
setProgress(null);
setSlug(baseSlug);
setPhase("ready");
} else if (d.status === "review") {
clearInterval(poll.current);
setProgress(null);
setPhase("review");
} else if (d.status === "failed" || d.status === "blocked") {
clearInterval(poll.current);
setProgress(null);
setPhase("failed");
setMsg(d.error || "The drawing couldn't be made. Try a different subject.");
}
@@ -65,6 +73,13 @@ export default function CreateForm({ level: initialLevel, levels, enabled }: { l
}, 2500);
}
function etaText(secs: number | null): string {
if (secs === null) return "estimating…";
if (secs <= 0) return "almost done…";
if (secs < 60) return `about ${secs}s left`;
return `about ${Math.ceil(secs / 60)} min left`;
}
if (!enabled) {
return (
<div className="card" style={{ marginTop: 16 }}>
@@ -107,8 +122,13 @@ export default function CreateForm({ level: initialLevel, levels, enabled }: { l
{phase === "generating" && (
<div className="card" style={{ marginTop: 12, textAlign: "center" }}>
<p style={{ margin: 0 }}>🎨 Drawing your {subject || "picture"} this can take a little while.</p>
<p className="muted" style={{ marginTop: 6, fontSize: "0.9rem" }}>You can stay on this page.</p>
<p style={{ margin: 0 }}>🎨 Drawing your {subject || "picture"}</p>
<div style={{ height: 10, borderRadius: 999, background: "var(--line)", overflow: "hidden", margin: "12px 0 6px" }}>
<div style={{ height: "100%", width: `${progress ? Math.max(4, progress.percent) : 4}%`, background: "var(--primary)", transition: "width 0.4s ease" }} />
</div>
<p className="muted" style={{ margin: 0, fontSize: "0.9rem" }}>
{progress ? `${progress.percent}% · ${etaText(progress.etaSeconds)}` : "Starting up…"}
</p>
</div>
)}
{phase === "review" && (
+30 -3
View File
@@ -54,9 +54,12 @@ async function sleep(ms: number) {
/**
* Generate coloring-book line art for the given subject. Returns the PNG bytes.
* Injects the subject into the workflow's prompt placeholder + a fresh seed.
* `onProgress` (best-effort, via ComfyUI's WebSocket) reports sampler step value/max for an ETA.
* Throws on timeout / ComfyUI errors (caller marks the lesson 'failed').
*/
export async function generateLineArt(subject: string): Promise<Buffer> {
export type ProgressFn = (p: { value: number; max: number }) => void;
export async function generateLineArt(subject: string, onProgress?: ProgressFn): Promise<Buffer> {
if (!isCreateEnabled()) throw new Error("Create is disabled (COMFYUI_URL not set).");
const workflow = loadWorkflow();
@@ -70,16 +73,39 @@ export async function generateLineArt(subject: string): Promise<Buffer> {
const clientId = `drawit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// Open a best-effort WebSocket for live step progress (ComfyUI has no "time left" endpoint).
let ws: WebSocket | undefined;
if (onProgress && typeof WebSocket !== "undefined") {
try {
const wsUrl = `${URL_BASE.replace(/^http/, "ws")}/ws?clientId=${clientId}`;
ws = new WebSocket(wsUrl);
ws.onmessage = (ev: MessageEvent) => {
if (typeof ev.data !== "string") return; // ignore binary preview frames
try {
const msg = JSON.parse(ev.data);
if (msg?.type === "progress" && msg.data && typeof msg.data.max === "number") {
onProgress({ value: Number(msg.data.value) || 0, max: Number(msg.data.max) || 0 });
}
} catch {
/* ignore */
}
};
ws.onerror = () => { /* best-effort; ignore */ };
} catch {
ws = undefined;
}
}
// 1) Queue the workflow.
const queueRes = await fetch(`${URL_BASE}/prompt`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
});
if (!queueRes.ok) throw new Error(`ComfyUI /prompt failed: ${queueRes.status} ${await queueRes.text()}`);
if (!queueRes.ok) { try { ws?.close(); } catch { /* */ } throw new Error(`ComfyUI /prompt failed: ${queueRes.status} ${await queueRes.text()}`); }
const queued = (await queueRes.json()) as { prompt_id?: string; error?: unknown };
const promptId = queued.prompt_id;
if (!promptId) throw new Error(`ComfyUI did not return a prompt_id: ${JSON.stringify(queued)}`);
if (!promptId) { try { ws?.close(); } catch { /* */ } throw new Error(`ComfyUI did not return a prompt_id: ${JSON.stringify(queued)}`); }
// 2) Poll history until the outputs appear (or timeout).
const deadline = Date.now() + TIMEOUT_MS;
@@ -97,6 +123,7 @@ export async function generateLineArt(subject: string): Promise<Buffer> {
}
if (image) break;
}
try { ws?.close(); } catch { /* */ }
if (!image) throw new Error("ComfyUI timed out before producing an image.");
// 3) Fetch the image bytes.
+14 -1
View File
@@ -122,6 +122,13 @@ export function promoteCreated(id: number) {
getDb().prepare("UPDATE created_lessons SET promoted = 1, updated_at = datetime('now') WHERE id = ?").run(id);
}
// Live generation progress (in-memory; single self-hosted node). Cleared when a run finishes.
export interface CreatedProgress { value: number; max: number; startedAt: number; updatedAt: number }
const progressStore = new Map<number, CreatedProgress>();
export function getCreatedProgress(id: number): CreatedProgress | undefined {
return progressStore.get(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
@@ -132,9 +139,13 @@ export async function processCreation(id: number): Promise<void> {
if (!row) return;
try {
updateCreatedStatus(id, "generating");
progressStore.set(id, { value: 0, max: 0, startedAt: Date.now(), updatedAt: Date.now() });
const { generateLineArt } = await import("./comfyui");
const { pngToLessonArt } = await import("./vectorize");
const png = await generateLineArt(row.subject);
const png = await generateLineArt(row.subject, (p) => {
const prev = progressStore.get(id);
progressStore.set(id, { value: p.value, max: p.max, startedAt: prev?.startedAt ?? Date.now(), updatedAt: Date.now() });
});
const art = await pngToLessonArt(png); // { outline, details, full }
// Auto-approved (allowlist) or admin-approved → live; anything else is quarantined for review.
const fresh = getCreatedById(id);
@@ -142,6 +153,8 @@ export async function processCreation(id: number): Promise<void> {
setGenerated(id, JSON.stringify(art), `data:image/png;base64,${png.toString("base64")}`, needsReview ? "review" : "ready");
} catch (e) {
updateCreatedStatus(id, "failed", e instanceof Error ? e.message : String(e));
} finally {
progressStore.delete(id);
}
}