[BUG] Emit stroked versions of the contours for ai images #17

Merged
iamdoubz merged 1 commits from bug_ai_outline into main 2026-06-29 13:49:02 -05:00
2 changed files with 53 additions and 13 deletions
+25 -9
View File
@@ -158,16 +158,28 @@ export async function processCreation(id: number): Promise<void> {
}
}
/** The stored line art: { outline, details, full }. Tolerates legacy rows that stored a bare SVG string. */
function lessonArt(row: CreatedLessonRow): { outline: string; details: string; full: string } {
interface StoredArt { outline: string; details: string; full: string; outlineStrokes: string[]; detailStrokes: string[] }
/** The stored line art. Tolerates legacy rows (bare SVG string, or pre-stroke JSON) by falling back. */
function lessonArt(row: CreatedLessonRow): StoredArt {
const raw = row.template_svg || "";
try {
const a = JSON.parse(raw);
if (a && typeof a === "object" && "full" in a) return { outline: a.outline || a.full || "", details: a.details || "", full: a.full || "" };
if (a && typeof a === "object" && "full" in a) {
const outline = a.outline || a.full || "";
return {
outline,
details: a.details || "",
full: a.full || "",
// Legacy rows lack stroke arrays → fall back to the filled path (fades in rather than draws).
outlineStrokes: Array.isArray(a.outlineStrokes) && a.outlineStrokes.length ? a.outlineStrokes : outline ? [outline] : [],
detailStrokes: Array.isArray(a.detailStrokes) ? a.detailStrokes : [],
};
}
} catch {
/* legacy: a plain SVG string */
}
return { outline: raw, details: "", full: raw };
return { outline: raw, details: "", full: raw, outlineStrokes: raw ? [raw] : [], detailStrokes: [] };
}
/**
@@ -177,7 +189,7 @@ function lessonArt(row: CreatedLessonRow): { outline: string; details: string; f
*/
export function buildCreatedLessons(row: CreatedLessonRow): Lesson[] {
const base = 900000 + row.id * 10;
const { outline, details, full } = lessonArt(row);
const { outline, full, outlineStrokes, detailStrokes } = lessonArt(row);
const lower = row.subject.toLowerCase();
const common = {
level: row.level,
@@ -189,6 +201,10 @@ export function buildCreatedLessons(row: CreatedLessonRow): Lesson[] {
badgeName: `${row.subject} Artist`,
badgeKey: "",
};
// Each stroked chunk is one step, so the pen draws the shape a few strokes at a time.
const toSteps = (strokes: string[], label: string) =>
strokes.map((el, i) => ({ n: i + 1, title: `${label} ${i + 1}`, instruction: "Watch the line, then trace it!", tip: "", lines: [el] }));
const lessons: Lesson[] = [];
lessons.push({
...common,
@@ -199,9 +215,9 @@ export function buildCreatedLessons(row: CreatedLessonRow): Lesson[] {
intro: `Trace the outline of your ${lower}, one line at a time.`,
phase: "outline",
baseSvg: "",
steps: [{ n: 1, title: "The outline", instruction: `Trace the main outline of your ${lower}.`, tip: "", lines: [outline] }],
steps: toSteps(outlineStrokes, "Outline"),
});
if (details) {
if (detailStrokes.length > 0) {
lessons.push({
...common,
sublevel: base + 2,
@@ -210,8 +226,8 @@ export function buildCreatedLessons(row: CreatedLessonRow): Lesson[] {
subject: `${lower} details`,
intro: `Now add the details inside your ${lower}.`,
phase: "detail",
baseSvg: outline, // faint outline guide under the new detail lines
steps: [{ n: 1, title: "The details", instruction: "Add the inside details.", tip: "", lines: [details] }],
baseSvg: outline, // faint filled outline as a reference under the new detail lines
steps: toSteps(detailStrokes, "Detail"),
});
}
lessons.push({
+28 -4
View File
@@ -48,9 +48,12 @@ function round(n: number): number {
}
export interface LessonArt {
outline: string; // one <path> — the main outline (big shapes)
details: string; // one <path> — interior details (may be "")
full: string; // one <path> — the whole line art (color template)
outline: string; // one filled <path> — the main outline (big shapes), for guides/reference
details: string; // one filled <path> — interior details (may be "")
full: string; // one filled <path> — the whole line art (color template)
// Stroked, chunked versions for the "watch it drawn" animation (stroke-dashoffset pen effect).
outlineStrokes: string[];
detailStrokes: string[];
}
export async function pngToLessonArt(png: Buffer): Promise<LessonArt> {
@@ -70,6 +73,21 @@ export async function pngToLessonArt(png: Buffer): Promise<LessonArt> {
const wrap = (subs: string[]) =>
subs.length ? `<path d="${subs.join(" ")}" transform="${transform}" fill="${STROKE}" fill-rule="evenodd"/>` : "";
// Stroke width chosen so it renders ~4px on the 400×300 canvas after the transform scale.
const sw = Math.max(2, round(4 / s));
// Split contours into a few stroked elements so the pen draws the shape in a handful of strokes.
const chunkStroke = (subs: string[], maxSteps: number): string[] => {
if (subs.length === 0) return [];
const n = Math.min(maxSteps, subs.length);
const per = Math.ceil(subs.length / n);
const out: string[] = [];
for (let i = 0; i < subs.length; i += per) {
const d = subs.slice(i, i + per).join(" ");
out.push(`<path d="${d}" transform="${transform}" fill="none" stroke="${STROKE}" stroke-width="${sw}" stroke-linecap="round" stroke-linejoin="round"/>`);
}
return out;
};
let outlineSubs = outline.subs;
const outlineSet = new Set(outlineSubs);
let detailSubs = full.subs.filter((d) => !outlineSet.has(d));
@@ -80,5 +98,11 @@ export async function pngToLessonArt(png: Buffer): Promise<LessonArt> {
detailSubs = [];
}
return { outline: wrap(outlineSubs), details: wrap(detailSubs), full: wrap(full.subs) };
return {
outline: wrap(outlineSubs),
details: wrap(detailSubs),
full: wrap(full.subs),
outlineStrokes: chunkStroke(outlineSubs, 4),
detailStrokes: chunkStroke(detailSubs, 3),
};
}