Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions app/(app)/projects/[id]/autoblog/setup/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,18 @@ type Existing = {
internal_links_per_article: number;
backlinks_enabled: boolean;
external_links_per_article: number;
banner_style: string | null;
status: string;
};

const BANNER_STYLE_OPTIONS: Array<{ value: string; label: string; hint: string }> = [
{ value: "editorial", label: "Editorial photo", hint: "Cinematic photojournalistic cover" },
{ value: "hype", label: "Marketing hype", hint: "Bold, energetic launch-poster look" },
{ value: "concept", label: "Concept illustration", hint: "Clean illustrated metaphor of the idea" },
{ value: "tech", label: "3D / isometric", hint: "Sleek 3D render of the topic" },
{ value: "bold_type", label: "Bold minimal", hint: "Striking minimal composition" },
];

const DAY_LABELS: Array<{ n: number; label: string }> = [
{ n: 1, label: "Mon" },
{ n: 2, label: "Tue" },
Expand Down Expand Up @@ -115,6 +124,7 @@ export function SetupForm({
const [seoTitle, setSeoTitle] = useState(initial?.seo_title ?? "");
const [seoDescription, setSeoDescription] = useState(initial?.seo_description ?? "");
const [tone, setTone] = useState(initial?.tone ?? "");
const [bannerStyle, setBannerStyle] = useState(initial?.banner_style ?? "editorial");
const [competitors, setCompetitors] = useState(
(initial?.competitors ?? []).join(", "),
);
Expand Down Expand Up @@ -566,6 +576,7 @@ export function SetupForm({
seoTitle,
seoDescription,
tone,
bannerStyle,
competitors,
webhookUrl,
webhookSecret,
Expand Down Expand Up @@ -933,6 +944,26 @@ export function SetupForm({
onChange={(e) => setTone(e.target.value)}
/>
</div>
<div>
<label className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
Banner image style
</label>
<select
className="input mt-1"
value={bannerStyle}
onChange={(e) => setBannerStyle(e.target.value)}
>
{BANNER_STYLE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<p className="mt-1 text-xs text-[var(--color-muted)]">
{BANNER_STYLE_OPTIONS.find((o) => o.value === bannerStyle)?.hint}
{" "}— every banner depicts the post's actual topic in this style.
</p>
</div>
<div>
<label className="text-xs uppercase tracking-wider text-[var(--color-muted)]">
SEO title (50–60 chars)
Expand Down
2 changes: 1 addition & 1 deletion app/(app)/projects/[id]/autoblog/setup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { SetupForm } from "./form";
export const metadata = { title: "Autoblog · Setup" };

const SITE_COLUMNS =
"id, domain, blog_root_url, sitemap_url, niche, target_audiences, description, seed_keywords, modifiers, preserve_keywords, keywords, seo_title, seo_description, tone, competitors, webhook_url, webhook_secret, daily_article_count, publish_days, publish_hour, internal_links_per_article, backlinks_enabled, external_links_per_article, status";
"id, domain, blog_root_url, sitemap_url, niche, target_audiences, description, seed_keywords, modifiers, preserve_keywords, keywords, seo_title, seo_description, tone, competitors, webhook_url, webhook_secret, daily_article_count, publish_days, publish_hour, internal_links_per_article, backlinks_enabled, external_links_per_article, banner_style, status";

export default async function AutoblogSetupPage({
params,
Expand Down
9 changes: 9 additions & 0 deletions app/actions/linkExchange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,9 @@ export type SiteInput = {
// (Link Exchange phase) ships separately.
backlinksEnabled?: boolean;
externalLinksPerArticle?: number;
// Autoblog hero/banner image style. See BANNER_STYLES in
// lib/lx/articleGen.ts. Defaults to 'editorial'.
bannerStyle?: string;
};

export async function createOrUpdateSite(
Expand Down Expand Up @@ -370,6 +373,10 @@ export async function createOrUpdateSite(
const internalLinks = Math.max(0, Math.min(8, Math.floor(input.internalLinksPerArticle ?? 3)));
const backlinksEnabled = !!input.backlinksEnabled;
const externalLinks = Math.max(0, Math.min(5, Math.floor(input.externalLinksPerArticle ?? 3)));
const BANNER_STYLE_IDS = ["editorial", "hype", "concept", "tech", "bold_type"];
const bannerStyle = BANNER_STYLE_IDS.includes(input.bannerStyle ?? "")
? (input.bannerStyle as string)
: "editorial";
const publishDays = Array.from(
new Set(
(input.publishDays ?? [1, 2, 3, 4, 5])
Expand Down Expand Up @@ -442,6 +449,7 @@ export async function createOrUpdateSite(
internal_links_per_article: internalLinks,
backlinks_enabled: backlinksEnabled,
external_links_per_article: externalLinks,
banner_style: bannerStyle,
next_publish_at: nextAt?.toISOString() ?? null,
})
.eq("id", existingByProject.id);
Expand Down Expand Up @@ -480,6 +488,7 @@ export async function createOrUpdateSite(
internal_links_per_article: internalLinks,
backlinks_enabled: backlinksEnabled,
external_links_per_article: externalLinks,
banner_style: bannerStyle,
next_publish_at: nextAt?.toISOString() ?? null,
})
.select("id")
Expand Down
104 changes: 86 additions & 18 deletions lib/lx/articleGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type SiteRow = {
internal_links_per_article: number;
backlinks_enabled: boolean;
external_links_per_article: number;
banner_style: BannerStyle | null;
};

type KeywordRow = {
Expand Down Expand Up @@ -522,11 +523,16 @@ async function findPriorArticles(
// at work, dramatic scenes, expressive moments) instead of abstract
// metallic objects. Mix subjects across articles so the blog index
// doesn't feel like one repeated style.
const SHARED_ART_DIRECTION = [
// Reference: visual benchmarks people stop scrolling for.
// Inline "concept" section images keep the photojournalistic tactile-metaphor
// look. The hero no longer forces this — its subject comes from the post
// content + the project's chosen banner style instead, so projects stop
// looking identical.
const INLINE_CONCEPT_DIRECTION = [
"Style benchmark: a Time Magazine / National Geographic / Bloomberg Businessweek / The Verge feature-cover photograph. Editorial photojournalism, not abstract digital art. Should feel like a published photograph, not a render.",
// Subject diversity — break the "geometric metal sculpture" default.
"Subject: pick the most cinematic option that fits the topic — a person at work with the topic visible in their environment (engineer at a screen, operator in a control room, hands on a keyboard with code reflected in glasses, a researcher at a whiteboard); OR a dramatic scene that evokes the topic (a server room glowing in low light, a city at dusk seen through a window, a single object spotlit on a desk surrounded by cluttered notes); OR a close-up of a real-world workspace artifact (open laptop, well-worn notebook, coffee-ringed printout, glowing terminal). Vary across articles. Faces and hands are welcome — they drive engagement.",
];

const SHARED_ART_DIRECTION = [
// Lighting: cinematic but believable.
"Lighting: cinematic chiaroscuro — one strong directional light source (window, monitor glow, desk lamp) with deep falloff into shadow, plus a subtle warm or cool fill. Mood is contemplative-intense, not corporate-bright. Volumetric light particles welcome.",
// Palette: richer and varied per piece.
Expand Down Expand Up @@ -707,6 +713,7 @@ function buildInlineImagePrompt(
`Editorial section image for a long-form technical SEO article.`,
`Concept to evoke (do NOT depict literally — find a tactile metaphor): ${spec.prompt}.`,
niche,
...INLINE_CONCEPT_DIRECTION,
...SHARED_ART_DIRECTION,
"Slightly more subdued than a hero — a chapter divider, not the cover. 3:2 aspect.",
]
Expand Down Expand Up @@ -743,6 +750,46 @@ export async function generateInlineImage(
return Buffer.from(b64, "base64");
}

export type BannerStyle = "editorial" | "hype" | "concept" | "tech" | "bold_type";

export const BANNER_STYLES: ReadonlyArray<{
id: BannerStyle;
label: string;
description: string;
}> = [
{
id: "editorial",
label: "Editorial photo",
description: "Cinematic photojournalistic cover — a real-feeling scene about the topic.",
},
{
id: "hype",
label: "Marketing hype",
description: "Bold, energetic launch-poster look. Punchy, high-contrast, scroll-stopping.",
},
{
id: "concept",
label: "Concept illustration",
description: "Clean illustrated metaphor of the post's core idea.",
},
{
id: "tech",
label: "3D / isometric",
description: "Sleek 3D or isometric render of the topic's objects and systems.",
},
{
id: "bold_type",
label: "Bold minimal",
description: "Striking minimal composition built around the topic's key motif.",
},
];

export function isBannerStyle(v: unknown): v is BannerStyle {
return (
typeof v === "string" && BANNER_STYLES.some((s) => s.id === v)
);
}

export type HeroImageMeta = {
title: string;
excerpt?: string | null;
Expand All @@ -751,21 +798,38 @@ export type HeroImageMeta = {
niche?: string | null;
audiences?: string[] | null;
brand?: string | null;
style?: BannerStyle | null;
};

// Per-style art direction. Every style anchors on the SAME post content
// (built below); this only changes the visual treatment so projects don't
// all look identical. Shared rules: cinematic, text-free, single focal
// subject, reads at thumbnail size.
const STYLE_DIRECTION: Record<BannerStyle, string> = {
editorial:
"Treatment: cinematic editorial cover PHOTOGRAPH (Time / National Geographic / The Verge feature). A real-feeling moment that depicts the topic — a charged scene, a dramatic object lit in context, or a person genuinely doing the thing the article is about. Chiaroscuro lighting, one committed color palette, film-like microcontrast, real-world texture. Looks shot, not rendered.",
hype:
"Treatment: bold MARKETING / HYPE poster — energetic product-launch key art. High-contrast, saturated, dynamic angles and motion, dramatic rim light and glow, a confident hero subject that embodies the topic. Feels like the splash image for a hot launch. Premium and exciting, NOT cheesy stock or clip-art.",
concept:
"Treatment: clean CONCEPT ILLUSTRATION — a single clear visual metaphor for the article's core idea, rendered in a modern flat/vector editorial style with depth and a tight palette. The metaphor must come from the post's actual subject, not generic tech doodles.",
tech:
"Treatment: sleek 3D / ISOMETRIC render of the concrete objects, systems, or flow the article is about (devices, pipelines, networks, structures depicted literally as polished 3D forms). Studio lighting, soft shadows, glossy/matte material contrast, one accent color. Modern SaaS hero-render aesthetic.",
bold_type:
"Treatment: striking MINIMAL composition built around the single key motif of the topic — one bold symbolic object or shape, generous negative space, dramatic lighting, one strong accent color against a restrained background. Graphic and confident.",
};

export async function generateImage(
openai: OpenAI,
meta: HeroImageMeta,
): Promise<Buffer | null> {
// Hero image. The thumbnail on /blog AND the OG card on every share —
// it has to stop a scroll on a phone. Aim for an editorial cover that
// a magazine art director would approve: a clear human or scene
// subject, dramatic light, one striking color combination.
// it has to stop a scroll on a phone AND actually depict THIS post.
//
// We feed the model the actual article metadata (title, lede,
// tags, audience) so it has more to anchor on than the bare headline.
// Without the lede, gpt-image-1 collapses every abstract title into
// the same generic geometric render.
// We feed the model the real article metadata (title, lede, tags,
// niche) so the image is about the content, then apply the project's
// chosen visual treatment. Without the lede, gpt-image-1 collapses
// every abstract title into the same generic render.
const style: BannerStyle = meta.style ?? "editorial";
const lede = (meta.excerpt || meta.metaDescription || "").trim();
const tagList = (meta.tags ?? [])
.map((t) => t.trim())
Expand All @@ -776,19 +840,22 @@ export async function generateImage(
.filter(Boolean)
.slice(0, 3);
const prompt = [
`Hero cover photograph for a long-form technical feature titled: "${meta.title}".`,
lede ? `What the piece is actually about: ${lede}` : "",
tagList.length > 0 ? `Topic tags: ${tagList.join(", ")}.` : "",
// Content FIRST — the image must depict this specific post.
`Create a hero banner image that visually depicts the subject of this article. Title: "${meta.title}".`,
lede ? `What it's actually about: ${lede}` : "",
tagList.length > 0 ? `Key concepts to depict: ${tagList.join(", ")}.` : "",
meta.niche ? `Subject area: ${meta.niche}.` : "",
"The image MUST be recognizably about this topic — a viewer should glance at it and understand what the article covers. Depict the actual subject, not a generic office or 'person at a laptop' scene.",
audienceList.length > 0
? `Audience the piece is for: ${audienceList.join("; ")}. If you place a person in the frame, they should plausibly belong to that audience (clothing, environment, tools).`
? `Audience: ${audienceList.join("; ")}. Any people shown should plausibly belong to it.`
: "",
meta.brand
? `Publication: ${meta.brand} — operator-focused technical journalism, not corporate marketing.`
? `Publication: ${meta.brand}.`
: "",
"Pick the single most cinematic, scroll-stopping visual that fits the topic — preferably a real-feeling moment (a person at work, a charged scene, a single dramatic object lit in context) rather than abstract geometric shapes. Make it look like a published feature-story photograph, NOT an AI-generated abstract pattern.",
// Visual treatment SECOND — varies per project, keeps the family look.
STYLE_DIRECTION[style],
...SHARED_ART_DIRECTION,
"Cinematic 3:2 magazine cover aspect. Single-image composition with a clear focal subject; never a collage. Must read well as a 320×213px social-card thumbnail.",
"Cinematic 3:2 aspect. Single-image composition with one clear focal subject; never a collage. Text-free (no words, labels, or logos). Must read well as a 320×213px social-card thumbnail.",
]
.filter(Boolean)
.join(" ");
Expand Down Expand Up @@ -856,7 +923,7 @@ export async function generateArticle(
const { data: site } = await supabase
.from("lx_site")
.select(
"id, user_id, domain, blog_root_url, niche, target_audiences, description, internal_links_per_article, backlinks_enabled, external_links_per_article, status",
"id, user_id, domain, blog_root_url, niche, target_audiences, description, internal_links_per_article, backlinks_enabled, external_links_per_article, banner_style, status",
)
.eq("id", siteId)
.maybeSingle();
Expand Down Expand Up @@ -1131,6 +1198,7 @@ export async function generateArticle(
niche: typedSite.niche,
audiences: typedSite.target_audiences,
brand: typedSite.domain ?? null,
style: typedSite.banner_style ?? "editorial",
});
if (bytes)
imageUrl = await uploadImage(supabase, typedSite.id, finalSlug, bytes);
Expand Down
17 changes: 17 additions & 0 deletions supabase/migrations/20260530210000_lx_banner_style.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
-- Autoblog hero/banner image style, per project (lx_site).
--
-- Until now every autoblog hero used one fixed art-direction block, so
-- every post across every project looked the same ("person at a screen").
-- This lets each project pick a banner style. All styles still anchor the
-- image on the POST's actual content (title + lede + tags + niche); they
-- only change the visual treatment.
--
-- editorial — cinematic photojournalistic cover (the original look)
-- hype — bold marketing / hype poster, energetic launch vibe
-- concept — clean concept illustration of the post's core idea
-- tech — sleek 3D / isometric render of the topic's objects/systems
-- bold_type — striking minimal composition around the topic's key motif

alter table public.lx_site
add column if not exists banner_style text not null default 'editorial'
check (banner_style in ('editorial','hype','concept','tech','bold_type'));
Loading