Skip to content

Commit f05d694

Browse files
ralyodioclaude
andcommitted
feat(autoblog): infographic-style inline images (charts/flows/etc)
Inline image generation was producing pretty-but-generic abstract-editorial blobs — the same tactile-metaphor language was applied to every section regardless of what the section was doing. For posts with quantitative content, comparison tables, or step-by-step workflows, the right image is a small infographic, not another sculpture macro. Let Claude pick the right kind per section. ArticleSchema's inline_image_prompts now carries: - kind: concept | chart | flow | comparison | checklist - labels: 2–6 short strings (≤24 chars) to render in the figure System prompt explains when to use each kind and asks for a MIX across the three slots — at least one structural visual when the article has any quantitative or procedural content. generateInlineImage dispatches on kind to a tailored art-direction overlay. New INFOGRAPHIC_ART_DIRECTION (paper background, single accent color, sans-serif, clean strokes, NO 3D / drop-shadows / emoji / faux-handwriting) is the visual language for chart/flow/ comparison/checklist. SHARED_ART_DIRECTION (the cinematic macro style) stays for hero + concept-mode inlines, keeping the magazine cover feel where it makes sense. Existing callers (articleGen + guestPostGen) updated to forward kind/labels through to the image renderer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ac86ee1 commit f05d694

2 files changed

Lines changed: 135 additions & 12 deletions

File tree

‎lib/lx/articleGen.ts‎

Lines changed: 131 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,17 @@ export const ArticleSchema = z.object({
101101
z.object({
102102
alt: z.string().min(5).max(200),
103103
prompt: z.string().min(20).max(500),
104+
// Per-image style. Drives the art-direction overlay in
105+
// generateInlineImage. Default "concept" matches the legacy
106+
// abstract-editorial behavior so older flows still render.
107+
kind: z
108+
.enum(["concept", "chart", "flow", "comparison", "checklist"])
109+
.default("concept"),
110+
// For chart/flow/comparison/checklist kinds: 2-6 short text
111+
// labels (≤24 chars each) that should appear in the image.
112+
// Empty / ignored for "concept". gpt-image-1 renders these
113+
// legibly when count is small and labels are short.
114+
labels: z.array(z.string().max(24)).max(6).default([]),
104115
}),
105116
)
106117
.min(INLINE_IMAGE_COUNT)
@@ -163,7 +174,20 @@ export function buildSystemPrompt(): string {
163174
"",
164175
"Internal links: insert each provided URL inline exactly once as a standard markdown `[anchor](url)` link where the surrounding sentence is genuinely about that URL's topic. Never create a \"Further reading\" list. Never invent URLs — use only those provided. Link candidates may include both site pages AND prior blog posts on this same site — treat both the same way (inline contextual anchor; the prior posts are clearly labeled in the candidate list).",
165176
"",
166-
`Inline images: place exactly ${INLINE_IMAGE_COUNT} placeholder lines of the form '<!--INLINE_IMAGE_1-->', '<!--INLINE_IMAGE_2-->', '<!--INLINE_IMAGE_3-->' (each on its own line, in numeric order) inside markdown_body. Place each marker on a blank line immediately after a major H2 boundary, distributed across the body — never inside the intro, the TOC, a blockquote, a table, or inside the final '### Try {brand}' CTA block. In inline_image_prompts return exactly ${INLINE_IMAGE_COUNT} objects in the same 1→${INLINE_IMAGE_COUNT} order: each with a short alt text describing what the image shows for accessibility, and an image generation prompt for that section's topic (abstract editorial style, no text/typography/logos/people).`,
177+
`Inline images: place exactly ${INLINE_IMAGE_COUNT} placeholder lines of the form '<!--INLINE_IMAGE_1-->', '<!--INLINE_IMAGE_2-->', '<!--INLINE_IMAGE_3-->' (each on its own line, in numeric order) inside markdown_body. Place each marker on a blank line immediately after a major H2 boundary, distributed across the body — never inside the intro, the TOC, a blockquote, a table, or inside the final '### Try {brand}' CTA block.`,
178+
"",
179+
`For each placeholder, return one object in inline_image_prompts (same 1→${INLINE_IMAGE_COUNT} order). Each object MUST set a kind that fits what the surrounding section is doing — this is the difference between a decorative blob and an informative graphic:`,
180+
"",
181+
"- kind=\"chart\" — when the section discusses metrics, percentages, comparisons of magnitudes, or trends. Provide 2–5 short labels (≤24 chars each) that should appear on bars/segments. Plausible numbers are fine; the chart is illustrative.",
182+
"- kind=\"flow\" — when the section walks through a process or workflow with 3–5 steps. Provide the step labels in order.",
183+
"- kind=\"comparison\" — when the section explicitly contrasts two approaches (good/bad, before/after, wrong/right). Provide exactly 2 labels (e.g., [\"Before\", \"After\"]).",
184+
"- kind=\"checklist\" — when the section is a 'do these things' list of 3–6 items. Provide the checklist labels.",
185+
"- kind=\"concept\" — fallback for atmospheric / opening sections where no data, flow, comparison, or list is being presented. No labels needed.",
186+
"",
187+
"Aim for a MIX across the three inline images — at least one should be chart/flow/comparison/checklist when the article has any quantitative or procedural content. 'concept' is fine for at most one of the three.",
188+
"",
189+
"alt: a short accessibility description of what the image shows.",
190+
"prompt: a one-sentence brief describing the image's content (this becomes the image-model prompt). Don't include style direction — the renderer adds it.",
167191
"",
168192
"Output: strict JSON matching the schema. The markdown_body is the article body only.",
169193
].join("\n");
@@ -397,20 +421,113 @@ const SHARED_ART_DIRECTION = [
397421
"Strictly NO text, NO typography, NO UI mockups, NO logos, NO charts with labels, NO people, NO faces, NO stock-photo office scenes, NO generic abstract gradient blobs.",
398422
];
399423

424+
// Visual-design language shared across all infographic-style inline
425+
// images. Keeps charts/flows/comparisons/checklists feeling like they
426+
// came from the same magazine even though Claude wrote each prompt
427+
// independently. Different from SHARED_ART_DIRECTION (which is for
428+
// "concept" mode + the hero) — that one explicitly forbids text;
429+
// this one explicitly permits it.
430+
const INFOGRAPHIC_ART_DIRECTION = [
431+
"Style: clean editorial infographic — the kind you'd see in a Bloomberg, Stripe Press, or Information is Beautiful piece. NOT a 3D render, NOT a slide template, NOT clip-art.",
432+
"Surface: matte off-white paper background (#F2EFE8-ish) with one charcoal accent (#1A1F26) and ONE saturated highlight color picked from {electric cyan, deep emerald, warm amber, vermilion} — pick one and stick to it for the whole figure.",
433+
"Typography: legible sans-serif (Inter / SF Pro feel), tight tracking, NO ALL-CAPS body text, generous whitespace. Labels short and unambiguous. Numbers rendered cleanly.",
434+
"Lines: 1.5–2px strokes, no drop shadows, no gradient fills unless explicitly part of a data series. Rounded corners on shape primitives.",
435+
"Layout: 3:2 aspect, asymmetric balance, single clear visual hierarchy. Title-free — the surrounding article copy provides the headline.",
436+
"Strictly NO photographic textures, NO 3D bevels, NO gradients on backgrounds, NO emoji, NO stock-icon people, NO faux-handwriting fonts, NO drop-shadows on text.",
437+
];
438+
439+
function buildInlineImagePrompt(
440+
spec: { prompt: string; kind: string; labels: string[] },
441+
nicheHint: string | null,
442+
): string {
443+
const labels = (spec.labels ?? []).map((l) => l.trim()).filter(Boolean);
444+
const labelClause = labels.length > 0
445+
? `Render these exact short text labels — spell them precisely, do not paraphrase: ${labels.map((l) => `"${l}"`).join(", ")}.`
446+
: "";
447+
const niche = nicheHint ? `Subject area: ${nicheHint}.` : "";
448+
449+
switch (spec.kind) {
450+
case "chart": {
451+
// Bar / column chart with 3–5 segments. Plausible illustrative numbers.
452+
return [
453+
"Editorial bar-chart infographic for a long-form technical article.",
454+
`Topic: ${spec.prompt}.`,
455+
niche,
456+
`Render a simple 3–5 segment vertical bar chart (or horizontal if it fits the labels better). Each bar has a clear short label beneath/beside it and one rendered numeric value on or above the bar (percentages OK).`,
457+
labelClause,
458+
"One bar should be visually emphasized in the accent color; the rest in charcoal.",
459+
...INFOGRAPHIC_ART_DIRECTION,
460+
]
461+
.filter(Boolean)
462+
.join(" ");
463+
}
464+
case "flow": {
465+
return [
466+
"Editorial process-flow diagram for a long-form technical article.",
467+
`Topic: ${spec.prompt}.`,
468+
niche,
469+
"Render a left-to-right (or top-to-bottom if labels are long) sequence of 3–5 rounded-rectangle nodes connected by single arrows. Each node contains one short label. The final node is the outcome and is emphasized in the accent color.",
470+
labelClause,
471+
...INFOGRAPHIC_ART_DIRECTION,
472+
]
473+
.filter(Boolean)
474+
.join(" ");
475+
}
476+
case "comparison": {
477+
return [
478+
"Editorial side-by-side comparison panel for a long-form technical article.",
479+
`Topic: ${spec.prompt}.`,
480+
niche,
481+
"Render exactly two vertical columns separated by a thin vertical divider. Each column has its label as the column header and 3–4 short bullet-style items below — one icon glyph (geometric primitive, NOT emoji) next to each. The 'good' / 'after' / 'right' column gets the accent color; the other is charcoal.",
482+
labelClause,
483+
...INFOGRAPHIC_ART_DIRECTION,
484+
]
485+
.filter(Boolean)
486+
.join(" ");
487+
}
488+
case "checklist": {
489+
return [
490+
"Editorial visual checklist for a long-form technical article.",
491+
`Topic: ${spec.prompt}.`,
492+
niche,
493+
"Render a stacked vertical list of 3–6 items, each prefixed with a small filled square in the accent color. Items aligned left, generous line height. No screenshot framing.",
494+
labelClause,
495+
...INFOGRAPHIC_ART_DIRECTION,
496+
]
497+
.filter(Boolean)
498+
.join(" ");
499+
}
500+
case "concept":
501+
default: {
502+
// Legacy tactile-metaphor path. Used when the section is
503+
// atmospheric and there's nothing structural to visualize.
504+
return [
505+
`Editorial section image for a long-form technical SEO article.`,
506+
`Concept to evoke (do NOT depict literally — find a tactile metaphor): ${spec.prompt}.`,
507+
niche,
508+
...SHARED_ART_DIRECTION,
509+
"Slightly more subdued than a hero — a chapter divider, not the cover. 3:2 aspect.",
510+
]
511+
.filter(Boolean)
512+
.join(" ");
513+
}
514+
}
515+
}
516+
400517
export async function generateInlineImage(
401518
openai: OpenAI,
402519
promptText: string,
403520
nicheHint: string | null,
521+
opts: { kind?: string; labels?: string[] } = {},
404522
): Promise<Buffer | null> {
405-
const prompt = [
406-
`Editorial section image for a long-form technical SEO article.`,
407-
`Concept to evoke (do NOT depict literally — find a tactile metaphor): ${promptText}.`,
408-
nicheHint ? `Subject area: ${nicheHint}.` : "",
409-
...SHARED_ART_DIRECTION,
410-
"Slightly more subdued than a hero — a chapter divider, not the cover. 3:2 aspect.",
411-
]
412-
.filter(Boolean)
413-
.join(" ");
523+
const prompt = buildInlineImagePrompt(
524+
{
525+
prompt: promptText,
526+
kind: opts.kind ?? "concept",
527+
labels: opts.labels ?? [],
528+
},
529+
nicheHint,
530+
);
414531
const res = await openai.images.generate({
415532
model: IMAGE_MODEL,
416533
prompt,
@@ -803,7 +920,10 @@ export async function generateArticle(
803920
})(),
804921
...article.inline_image_prompts.map(async (p, i) => {
805922
try {
806-
const bytes = await generateInlineImage(openai, p.prompt, typedSite.niche);
923+
const bytes = await generateInlineImage(openai, p.prompt, typedSite.niche, {
924+
kind: p.kind,
925+
labels: p.labels,
926+
});
807927
if (bytes) {
808928
inlineImageUrls[i] = await uploadImage(
809929
supabase,

‎lib/lx/guestPostGen.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,10 @@ export async function generateGuestPost(
245245
})(),
246246
...article.inline_image_prompts.map(async (p, i) => {
247247
try {
248-
const bytes = await generateInlineImage(openai, p.prompt, target.niche);
248+
const bytes = await generateInlineImage(openai, p.prompt, target.niche, {
249+
kind: (p as { kind?: string }).kind,
250+
labels: (p as { labels?: string[] }).labels,
251+
});
249252
if (bytes) {
250253
inlineImageUrls[i] = await uploadImage(
251254
supabase,

0 commit comments

Comments
 (0)