Add RRSS auto-generation via the Ads Claude/Freepik/Canva pipeline.

Pipeline pieces can be created manually or enqueued with channel=rrss, using brandbook, calendar copy and RRSS history; n8n writes results back to creative_pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
4Ventures
2026-10-14 16:21:24 +02:00
parent 0c1ff3cc67
commit 9d7d799ec1
11 changed files with 1085 additions and 112 deletions

View File

@@ -0,0 +1,33 @@
# Skill de formato · Carrusel RRSS (slides 4:5)
> Carrusel orgánico: **3–5 slides** en **4:5**. Canal `rrss` → Pipeline (`slide_paths` + cover = slide 1).
## Entrega por variante
```json
{
"angle": "ángulo del carrusel",
"hook": "titular del slide 1, máx. 8 palabras",
"support": "subtítulo slide 1 o null",
"caption": "caption del post carrusel completo",
"cta": "CTA final (slide último / caption)",
"slides": [
{
"role": "hook",
"hook": "texto slide",
"support": null,
"image_prompt": "prompt EN INGLÉS 4:5 para este slide",
"composition_notes": "montaje slide"
}
],
"layout": "foto-con-franja | tipografico | dato-grande",
"rationale": "por qué esta secuencia"
}
```
## Reglas
- Slide 1 = gancho; medios = valor; último = CTA / guardado.
- Misma paleta y tono en todos los slides.
- Cada `image_prompt` termina con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 with negative space for headline`.
- Si el calendario ya trae estructura de carrusel, respétala.

32
skills/formats/post.md Normal file
View File

@@ -0,0 +1,32 @@
# Skill de formato · Post RRSS (feed IG/FB 4:5)
> Se aplica junto a la skill del cliente (brandbook). Produce una pieza **orgánica** para el Pipeline RRSS, no un anuncio Meta Ads.
> Diseño base **4:5 (1080×1350)**. Una sola pieza feed (no hace falta landscape Ads).
## Contexto de entrada
El brief trae `channel: "rrss"`, pieza de calendario (`hook`, `body`, `cta`, `hashtags`, `pillar`), brandbook y, si existe, histórico de pipeline / posts orgánicos. **Respeta el calendario**: no inventes otro tema; mejora y visualiza lo planificado.
## Qué entregas por cada variante
```json
{
"angle": "ángulo corto alineado al pilar del calendario",
"hook": "titular EN la imagen, máx. 8 palabras",
"support": "línea de apoyo en imagen, máx. 12 palabras, o null",
"caption": "copy del post (hook + cuerpo + CTA + hashtags útiles), tono de marca, máx. 2200 caracteres",
"cta": "CTA suave orgánico (p. ej. 'Guarda este post', 'Escríbenos', 'Link en bio')",
"image_prompt": "prompt EN INGLÉS para Freepik, sin texto ni logo, vertical 4:5, aire para titular",
"layout": "foto-con-franja | tipografico | dato-grande",
"composition_notes": "montaje 4:5 en 3-5 líneas",
"rationale": "por qué encaja con el calendario y el ADN"
}
```
## Reglas
- Prioriza el copy del calendario; puedes pulirlo, no cambiar de pilar.
- Sin claims prohibidos del brandbook. Sin emojis si la política lo prohíbe.
- `image_prompt` termina con: `no text, no logos, no watermarks, photorealistic, vertical 4:5 composition with clear negative space for a headline`.
- Hashtags: solo si el calendario o la marca los usan; máx. 5–8, relevantes.
- Write-back: n8n guarda en `creative_pipeline` (cover + caption), no en `creatives` Ads.

24
skills/formats/reel.md Normal file
View File

@@ -0,0 +1,24 @@
# Skill de formato · Reel RRSS (cover 9:16)
> Cover/keyframe de Reel **9:16**. El motion se produce aparte; aquí entregas imagen + caption del Reel.
## Entrega por variante
```json
{
"angle": "ángulo corto",
"hook": "texto en cover, máx. 7 palabras",
"support": "subtítulo o null",
"caption": "caption del Reel (gancho + valor + CTA + hashtags), tono de marca",
"cta": "CTA orgánico",
"image_prompt": "prompt EN INGLÉS, vertical 9:16, escena con movimiento implícito / freeze frame potente",
"layout": "foto-con-franja | tipografico | dato-grande",
"composition_notes": "cover 9:16 legible en feed de Reels",
"rationale": "encaje con calendario"
}
```
## Reglas
- El cover debe leerse parado (scroll). Misma disciplina de marca que posts.
- `image_prompt` termina con: `no text, no logos, no watermarks, photorealistic, vertical 9:16 cinematic still with negative space for a short hook`.

25
skills/formats/story.md Normal file
View File

@@ -0,0 +1,25 @@
# Skill de formato · Stories RRSS (9:16)
> Pieza orgánica vertical **9:16 (1080×1920)** para Instagram/Facebook Stories. Canal `rrss` → Pipeline.
## Entrega por variante
```json
{
"angle": "ángulo corto",
"hook": "texto en story, máx. 6 palabras, muy legible",
"support": "sticker/pregunta o segunda línea, o null",
"caption": "texto opcional de acompañamiento / nota interna (Stories no siempre llevan caption largo)",
"cta": "sticker CTA (p. ej. 'Responde', 'Desliza', 'Enlace')",
"image_prompt": "prompt EN INGLÉS, vertical 9:16, zona segura superior/inferior para UI de Stories",
"layout": "foto-con-franja | tipografico | dato-grande",
"composition_notes": "safe zones 9:16, dónde va el hook",
"rationale": "encaje con calendario"
}
```
## Reglas
- Respeta la pieza del calendario (serie, interactiva, promo…).
- Aire para stickers y UI nativa. Sin claims prohibidos.
- `image_prompt` termina con: `no text, no logos, no watermarks, photorealistic, vertical 9:16 with safe margins for Instagram Stories UI`.

View File

@@ -0,0 +1,264 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
import {
BRANDBOOK_DOC_TYPE,
brandbookToPrompt,
parseBrandbook,
} from '@/lib/brandbook';
import {
buildRrssGenerationBrief,
generationRequestNoteMarker,
isRrssPipelineFormat,
type RrssPipelineFormat,
} from '@/lib/rrss-generation';
import { createClient as createAdminClient } from '@supabase/supabase-js';
export const dynamic = 'force-dynamic';
function admin() {
return createAdminClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
}
/**
* POST — create pipeline piece + generation_request (channel=rrss) in one shot.
* Body: { title, format, month?, caption?, notes?, pillar?, calendar_piece_id?,
* calendar_piece?, scheduled_at?, channels?, variants_requested? }
*/
export async function POST(req: NextRequest) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!isAgencyRole(userRole(user))) {
return NextResponse.json({ error: 'Solo agencia' }, { status: 403 });
}
const ctx = await resolveActiveTenant(user);
if (!ctx.tenantId || ctx.portfolioWide) {
return NextResponse.json(
{ error: 'Selecciona un cliente en Contexto' },
{ status: 400 }
);
}
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'JSON inválido' }, { status: 400 });
}
const title = String(body.title || '').trim();
const format = body.format as RrssPipelineFormat;
if (!title) {
return NextResponse.json({ error: 'title requerido' }, { status: 400 });
}
if (!isRrssPipelineFormat(format)) {
return NextResponse.json(
{ error: 'format debe ser post|story|reel|carrusel' },
{ status: 400 }
);
}
const db = admin();
const tenantId = ctx.tenantId;
const { data: tenant } = await db
.from('tenants')
.select('id, slug, name')
.eq('id', tenantId)
.maybeSingle();
// Recent pipeline for continuity
const { data: recentRows } = await db
.from('creative_pipeline')
.select('title, format, caption')
.eq('tenant_id', tenantId)
.order('created_at', { ascending: false })
.limit(12);
// Brandbook
let brandbookPrompt: string | null = null;
const { data: brandDoc } = await db
.from('documents')
.select('content')
.eq('tenant_id', tenantId)
.eq('type', BRANDBOOK_DOC_TYPE)
.order('updated_at', { ascending: false })
.limit(1)
.maybeSingle();
if (brandDoc?.content) {
try {
const raw =
typeof brandDoc.content === 'string'
? JSON.parse(brandDoc.content)
: brandDoc.content;
const book = parseBrandbook(raw);
if (book) brandbookPrompt = brandbookToPrompt(book);
} catch {
/* ignore */
}
}
// Social cache (optional)
let socialHistory: unknown = null;
const { data: integs } = await db
.from('integrations')
.select('provider, meta')
.eq('tenant_id', tenantId)
.in('provider', ['instagram', 'facebook']);
if (integs?.length) {
socialHistory = integs.map((i) => ({
provider: i.provider,
cache: (i.meta as { social_cache?: unknown } | null)?.social_cache || null,
}));
}
const calendarPieceId =
typeof body.calendar_piece_id === 'string' ? body.calendar_piece_id : null;
const calendarPiece =
body.calendar_piece && typeof body.calendar_piece === 'object'
? (body.calendar_piece as Record<string, unknown>)
: null;
const caption =
typeof body.caption === 'string' ? body.caption.trim() || null : null;
const pillar =
typeof body.pillar === 'string' ? body.pillar.trim() || null : null;
const month =
typeof body.month === 'string'
? body.month
: typeof calendarPiece?.date === 'string'
? String(calendarPiece.date).slice(0, 7)
: null;
const notesBase =
typeof body.notes === 'string' ? body.notes.trim() : pillar ? `Pilar: ${pillar}` : '';
const { data: item, error: insErr } = await db
.from('creative_pipeline')
.insert({
tenant_id: tenantId,
title,
format,
month,
status: 'borrador',
caption,
notes: notesBase || null,
calendar_piece_id: calendarPieceId,
scheduled_at:
typeof body.scheduled_at === 'string' ? body.scheduled_at : null,
channels: Array.isArray(body.channels)
? body.channels
: ['instagram', 'facebook'],
created_by: user.id,
})
.select('*')
.single();
if (insErr || !item) {
return NextResponse.json(
{ error: insErr?.message || 'No se pudo crear la pieza' },
{ status: 500 }
);
}
const brief = buildRrssGenerationBrief({
tenantId,
clientSlug: tenant?.slug || 'cliente',
clientName: tenant?.name || ctx.label || 'Cliente',
format,
pipelineId: item.id,
calendarPieceId,
title,
caption,
notes: notesBase,
pillar,
month,
scheduledAt:
typeof body.scheduled_at === 'string' ? body.scheduled_at : null,
calendarPiece: calendarPiece
? {
id: calendarPieceId,
title: String(calendarPiece.title || title),
date: calendarPiece.date ? String(calendarPiece.date) : null,
time: calendarPiece.time ? String(calendarPiece.time) : null,
format: calendarPiece.format ? String(calendarPiece.format) : format,
pillar: calendarPiece.pillar
? String(calendarPiece.pillar)
: pillar,
hook: calendarPiece.hook ? String(calendarPiece.hook) : null,
body: calendarPiece.body
? String(calendarPiece.body)
: caption,
cta: calendarPiece.cta ? String(calendarPiece.cta) : null,
hashtags: calendarPiece.hashtags
? String(calendarPiece.hashtags)
: null,
platform: calendarPiece.platform
? String(calendarPiece.platform)
: 'instagram',
}
: null,
recentPipeline: (recentRows || []).map((r) => ({
title: r.title,
format: r.format,
caption: r.caption,
})),
brandbookPrompt,
socialHistory,
variantsRequested: Number(body.variants_requested || 2),
});
const { data: gr, error: grErr } = await db
.from('generation_requests')
.insert({
tenant_id: tenantId,
reason: 'calendario',
source_creative_id: null,
brief,
status: 'pendiente',
})
.select('*')
.single();
if (grErr || !gr) {
return NextResponse.json(
{
error: grErr?.message || 'Pieza creada pero falló la cola Claude',
item,
},
{ status: 502 }
);
}
const marker = generationRequestNoteMarker(gr.id);
const notesWithMarker = [notesBase, marker, 'Auto: Claude + Freepik + Canva']
.filter(Boolean)
.join('\n');
await db
.from('creative_pipeline')
.update({ notes: notesWithMarker, updated_at: new Date().toISOString() })
.eq('id', item.id)
.eq('tenant_id', tenantId);
dispatchN8nWebhookAsync('generation_request.created', {
generation_request: { ...gr, client_id: tenantId },
});
return NextResponse.json({
ok: true,
item: { ...item, notes: notesWithMarker },
generation_request: gr,
message:
'Pieza en cola. n8n/Claude generará cover+caption y escribirá en el pipeline.',
});
}

View File

@@ -4,6 +4,10 @@ import { isAgencyRole, userRole } from '@/lib/agency';
import { resolveActiveTenant } from '@/lib/tenant-context';
import { dispatchN8nWebhookAsync } from '@/lib/creatives-webhooks';
import { staticAdBriefAspectFields } from '@/lib/creatives-aspect-ratios';
import {
isRrssPipelineFormat,
rrssBriefAspectFields,
} from '@/lib/rrss-generation';
import { createClient as createAdminClient } from '@supabase/supabase-js';
function getAdmin() {
@@ -145,23 +149,48 @@ export async function POST(req: NextRequest) {
const avoidAngles = body.avoid_angles || (creativeAngle ? [creativeAngle] : []);
const format = body.format || 'estatico';
const channel =
body.channel === 'rrss' || body.brief?.channel === 'rrss' ? 'rrss' : 'ads';
const format =
body.format ||
(channel === 'rrss' ? 'post' : 'estatico');
const aspectFields =
channel === 'rrss' && isRrssPipelineFormat(format)
? rrssBriefAspectFields(format)
: staticAdBriefAspectFields(format);
const brief = {
tenant_id: tenantId,
client_id: tenantId,
client_slug: tenant?.slug || 'cliente',
reason: body.reason || 'manual',
source_creative_id: body.source_creative_id || null,
client_name: tenant?.name || null,
channel,
reason: body.reason || (channel === 'rrss' ? 'calendario' : 'manual'),
source_creative_id:
channel === 'rrss' ? null : body.source_creative_id || null,
source_pipeline_id: body.source_pipeline_id || null,
calendar_piece_id: body.calendar_piece_id || null,
format,
...staticAdBriefAspectFields(format),
variants_requested: Number(body.variants_requested || 3),
campaign_name: campaignName,
adset_id: adsetId,
audience,
product,
landing_url: landingUrl,
skill_format: body.skill_format || format,
...aspectFields,
variants_requested: Number(
body.variants_requested || (channel === 'rrss' ? 2 : 3)
),
campaign_name: channel === 'rrss' ? null : campaignName,
adset_id: channel === 'rrss' ? null : adsetId,
audience: channel === 'rrss' ? null : audience,
product: channel === 'rrss' ? null : product,
landing_url: channel === 'rrss' ? null : landingUrl,
avoid_angles: avoidAngles,
title: body.title || null,
caption: body.caption || null,
pillar: body.pillar || null,
month: body.month || null,
scheduled_at: body.scheduled_at || null,
calendar_piece: body.calendar_piece || null,
context: body.context || null,
write_back: body.write_back || null,
notes: body.notes || null,
};
@@ -181,21 +210,23 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
// Registrar en creative_events
await admin.from('creative_events').insert({
tenant_id: tenantId,
creative_id: brief.source_creative_id,
event_type: 'generation_requested',
actor: user ? `user:${user.id}` : 'agency',
from_status: null,
to_status: null,
payload: {
generation_request_id: created.id,
reason: brief.reason,
variants_requested: brief.variants_requested,
},
visibility: 'agency',
});
// Registrar en creative_events (solo Ads: FK a creatives)
if (channel === 'ads' && brief.source_creative_id) {
await admin.from('creative_events').insert({
tenant_id: tenantId,
creative_id: brief.source_creative_id,
event_type: 'generation_requested',
actor: user ? `user:${user.id}` : 'agency',
from_status: null,
to_status: null,
payload: {
generation_request_id: created.id,
reason: brief.reason,
variants_requested: brief.variants_requested,
},
visibility: 'agency',
});
}
const enrichedRequest = { ...created, client_id: created.tenant_id };
dispatchN8nWebhookAsync('generation_request.created', {

View File

@@ -0,0 +1,240 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticateIntegrationRequest } from '@/lib/integrations-auth';
import { createClient } from '@supabase/supabase-js';
import { ASSETS_BUCKET, serviceSupabase } from '@/lib/assets';
import {
parseGenerationRequestIdFromNotes,
stripGenerationRequestMarker,
} from '@/lib/rrss-generation';
export const dynamic = 'force-dynamic';
function admin() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
);
}
async function downloadToStorage(opts: {
tenantId: string;
pipelineId: string;
url: string;
filename: string;
}): Promise<string | null> {
const svc = serviceSupabase();
if (!svc) return null;
try {
const r = await fetch(opts.url, { cache: 'no-store' });
if (!r.ok) return null;
const buf = Buffer.from(await r.arrayBuffer());
const contentType = r.headers.get('content-type') || 'image/jpeg';
const ext = contentType.includes('png')
? 'png'
: contentType.includes('webp')
? 'webp'
: 'jpg';
const path = `${opts.tenantId}/pipeline/${opts.pipelineId}/${opts.filename}.${ext}`;
const up = await svc.storage.from(ASSETS_BUCKET).upload(path, buf, {
contentType,
upsert: true,
});
if (up.error) return null;
return path;
} catch {
return null;
}
}
/**
* POST — n8n write-back for RRSS auto-generation.
* Body: {
* generation_request_id?,
* pipeline_id?,
* asset_url?, // final or Freepik raw
* stories_asset_url?,
* caption?,
* hook?, support?,
* slide_urls?: string[],
* status?: 'borrador'|'revision_agencia',
* mark_request?: 'completada'|'fallida',
* error?: string
* }
*/
export async function POST(req: NextRequest) {
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
const auth = await authenticateIntegrationRequest(req, {
requireTenant: true,
parsedBody: body,
});
if (!auth.ok) return auth.errorResponse!;
const tenantId = auth.tenantId!;
const db = admin();
const grId =
typeof body.generation_request_id === 'string'
? body.generation_request_id
: null;
let pipelineId =
typeof body.pipeline_id === 'string' ? body.pipeline_id : null;
if (!pipelineId && grId) {
const { data: gr } = await db
.from('generation_requests')
.select('brief')
.eq('id', grId)
.eq('tenant_id', tenantId)
.maybeSingle();
const brief = (gr?.brief || {}) as {
source_pipeline_id?: string;
write_back?: { pipeline_id?: string };
};
pipelineId =
brief.source_pipeline_id || brief.write_back?.pipeline_id || null;
}
if (!pipelineId) {
return NextResponse.json(
{ error: 'pipeline_id or generation_request_id with source_pipeline_id required' },
{ status: 400 }
);
}
const { data: item } = await db
.from('creative_pipeline')
.select('id, notes, caption, format')
.eq('id', pipelineId)
.eq('tenant_id', tenantId)
.maybeSingle();
if (!item) {
return NextResponse.json({ error: 'Pipeline piece not found' }, { status: 404 });
}
const assetUrl =
typeof body.asset_url === 'string'
? body.asset_url
: typeof body.stories_asset_url === 'string'
? body.stories_asset_url
: null;
let coverPath: string | null = null;
if (assetUrl) {
if (assetUrl.startsWith('http')) {
coverPath = await downloadToStorage({
tenantId,
pipelineId,
url: assetUrl,
filename: 'cover-ai',
});
// Fallback: store URL so UI can still show something via <img src>
if (!coverPath) coverPath = assetUrl;
} else {
coverPath = assetUrl;
}
}
const slideUrls = Array.isArray(body.slide_urls)
? body.slide_urls.filter((u): u is string => typeof u === 'string')
: [];
const slidePaths: string[] = [];
for (let i = 0; i < slideUrls.length; i++) {
const u = slideUrls[i];
if (u.startsWith('http')) {
const p = await downloadToStorage({
tenantId,
pipelineId,
url: u,
filename: `slide-${i + 1}-ai`,
});
slidePaths.push(p || u);
} else {
slidePaths.push(u);
}
}
const caption =
typeof body.caption === 'string'
? body.caption
: [body.hook, body.support, body.primary_text]
.filter((x) => typeof x === 'string' && x.trim())
.join('\n\n') || item.caption;
const status =
body.status === 'revision_agencia' || body.status === 'borrador'
? body.status
: coverPath
? 'revision_agencia'
: 'borrador';
const cleanNotes = stripGenerationRequestMarker(item.notes);
const grMarker = grId || parseGenerationRequestIdFromNotes(item.notes);
const notes = [
cleanNotes,
grMarker ? `[[generation_request:${grMarker}]]` : null,
coverPath ? 'Generado: Claude + Freepik + Canva' : null,
]
.filter(Boolean)
.join('\n');
const patch: Record<string, unknown> = {
notes,
updated_at: new Date().toISOString(),
status,
};
if (coverPath) {
patch.cover_path = coverPath;
patch.cover_mime = coverPath.includes('.png')
? 'image/png'
: coverPath.includes('.webp')
? 'image/webp'
: 'image/jpeg';
}
if (caption) patch.caption = caption;
if (slidePaths.length) patch.slide_paths = slidePaths;
const { data: updated, error } = await db
.from('creative_pipeline')
.update(patch)
.eq('id', pipelineId)
.eq('tenant_id', tenantId)
.select('*')
.single();
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
if (grId) {
const mark =
body.mark_request === 'fallida'
? 'fallida'
: body.error
? 'fallida'
: 'completada';
await db
.from('generation_requests')
.update({
status: mark,
completed_at: new Date().toISOString(),
error: typeof body.error === 'string' ? body.error : null,
result: {
pipeline_id: pipelineId,
cover_path: coverPath,
notes: 'rrss write-back',
},
})
.eq('id', grId)
.eq('tenant_id', tenantId);
}
return NextResponse.json({ ok: true, item: updated });
}

View File

@@ -36,7 +36,14 @@ export async function GET(req: NextRequest) {
}
const slug = (tenant.slug || 'bps').toLowerCase().trim();
const formatParam = (req.nextUrl.searchParams.get('format') || 'estatico').toLowerCase().trim();
// Prefer explicit format; RRSS briefs also send skill_format=post|story|reel|carrusel
const formatParam = (
req.nextUrl.searchParams.get('skill_format') ||
req.nextUrl.searchParams.get('format') ||
'estatico'
)
.toLowerCase()
.trim();
// 2. Obtener brandbook de documents (fuente central de verdad: Zona de Marca)
const { data: brandDoc } = await adminClient

View File

@@ -30,6 +30,7 @@ import {
scheduledAtFromPiece,
} from '@/lib/calendar-documents';
import { captionHasProductionNoise } from '@/lib/publish-caption';
import { parseGenerationRequestIdFromNotes } from '@/lib/rrss-generation';
import {
AGENCY_STATUS_ORDER,
APPROVAL_COLOR,
@@ -95,6 +96,7 @@ const FORMAT_CONFIG: Record<string, { label: string; badgeCls: string }> = {
function mediaUrl(path: string | null) {
if (!path) return null;
if (/^https?:\/\//i.test(path)) return path;
return `/api/creative-pipeline/media?path=${encodeURIComponent(path)}`;
}
@@ -138,6 +140,8 @@ export default function CreativePipelineBoard() {
const [newDate, setNewDate] = useState('');
const [newTime, setNewTime] = useState('10:00');
const [syncCalendar, setSyncCalendar] = useState(true);
/** manual = upload creativo; auto = Claude + Freepik + Canva (mismo motor Ads) */
const [creationMode, setCreationMode] = useState<'manual' | 'auto'>('manual');
const [coverFile, setCoverFile] = useState<File | null>(null);
const [coverPreview, setCoverPreview] = useState<string | null>(null);
const [slideFiles, setSlideFiles] = useState<File[]>([]);
@@ -277,6 +281,7 @@ export default function CreativePipelineBoard() {
setNewDate('');
setNewTime('10:00');
setSyncCalendar(true);
setCreationMode('manual');
clearMediaSelection();
setShowForm(false);
}
@@ -339,6 +344,10 @@ export default function CreativePipelineBoard() {
try {
let calendarPieceId: string | null =
calPick !== '__new__' ? calPick : null;
const pickedCal =
calendarPieceId && calendarPieceId !== '__new__'
? calPieces.find((p) => p.id === calendarPieceId)
: null;
if (syncCalendar) {
const date =
@@ -374,6 +383,60 @@ export default function CreativePipelineBoard() {
? scheduledAtFromPiece({ date: newDate, time: newTime })
: null;
if (creationMode === 'auto') {
const res = await fetch('/api/creative-pipeline/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: newTitle.trim(),
format: newFormat,
month: newMonth || (newDate ? newDate.slice(0, 7) : null),
caption: newCaption || null,
notes: newPillar ? `Pilar: ${newPillar}` : null,
pillar: newPillar || null,
calendar_piece_id: calendarPieceId,
scheduled_at: sched,
channels: ['instagram', 'facebook'],
calendar_piece: pickedCal
? {
id: pickedCal.id,
title: pickedCal.title,
date: pickedCal.date,
time: pickedCal.time,
format: pickedCal.format,
pillar: pickedCal.pillar || newPillar,
hook: newHook || pickedCal.hook,
body: newCaption || pickedCal.body,
cta: pickedCal.cta,
hashtags: pickedCal.hashtags,
platform: pickedCal.platform || 'instagram',
}
: {
title: newTitle.trim(),
date: newDate || null,
time: newTime || null,
pillar: newPillar || null,
hook: newHook || null,
body: newCaption || null,
platform: 'instagram',
},
variants_requested: 2,
}),
});
const data = await res.json();
if (!res.ok) {
setErr(data.error || 'No se pudo encolar la generación RRSS');
return;
}
resetForm();
setWarning(
data.message ||
'Pieza en cola: Claude + Freepik + Canva generarán el creativo (mismo motor que Ads).'
);
await load();
return;
}
const res = await fetch('/api/creative-pipeline', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1005,91 +1068,131 @@ export default function CreativePipelineBoard() {
)}
</div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-xs font-semibold text-gray-700">
Creativo / imagen
</label>
<p className="mt-0.5 text-[11px] text-gray-500">
Sube la portada aquí (jpg/png/webp). En carrusel puedes añadir
slides extra.
<div className="sm:col-span-2 lg:col-span-3 rounded-xl border border-gray-200 bg-gray-50 p-3 space-y-2">
<p className="text-xs font-semibold text-gray-800">
Cómo crear el creativo
</p>
<div className="mt-2 flex flex-wrap items-start gap-4">
<label className="relative flex h-36 w-28 cursor-pointer flex-col items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 hover:border-brand-500 hover:bg-brand-50/40">
{coverPreview ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={coverPreview}
alt="Portada"
className="h-full w-full object-cover"
/>
) : (
<>
<ImagePlus className="h-7 w-7 text-gray-400" />
<span className="mt-1 px-2 text-center text-[10px] font-medium text-gray-500">
Portada
</span>
</>
)}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="absolute inset-0 cursor-pointer opacity-0"
onChange={(e) =>
pickCover(e.target.files?.[0] || null)
}
/>
</label>
{coverFile ? (
<div className="flex min-w-0 flex-1 flex-col gap-1 pt-1">
<p className="truncate text-xs font-medium text-gray-800">
{coverFile.name}
</p>
<p className="text-[11px] text-gray-500">
{(coverFile.size / 1024).toFixed(0)} KB
</p>
<button
type="button"
onClick={() => pickCover(null)}
className="w-fit text-xs font-medium text-red-600 hover:underline"
>
Quitar portada
</button>
</div>
) : null}
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setCreationMode('manual')}
className={`rounded-lg px-3 py-2 text-xs font-semibold border ${
creationMode === 'manual'
? 'bg-gray-900 text-white border-gray-900'
: 'bg-white text-gray-700 border-gray-200'
}`}
>
Manual — subo yo la imagen
</button>
<button
type="button"
onClick={() => {
setCreationMode('auto');
clearMediaSelection();
}}
className={`rounded-lg px-3 py-2 text-xs font-semibold border ${
creationMode === 'auto'
? 'bg-brand-600 text-white border-brand-600'
: 'bg-white text-gray-700 border-gray-200'
}`}
>
Auto — Claude + Freepik + Canva
</button>
</div>
<p className="text-[11px] text-gray-500">
{creationMode === 'auto'
? 'Usa Zona de marca, el calendario editorial y el histórico RRSS/pipeline. Misma cola n8n que Ads (channel=rrss); el resultado vuelve al pipeline.'
: 'Sube portada (y slides si es carrusel) como hasta ahora.'}
</p>
</div>
{newFormat === 'carrusel' ? (
<div className="mt-3">
<label className="block text-xs font-medium text-gray-600">
Slides del carrusel (opcionales)
{creationMode === 'manual' ? (
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-xs font-semibold text-gray-700">
Creativo / imagen
</label>
<p className="mt-0.5 text-[11px] text-gray-500">
Sube la portada aquí (jpg/png/webp). En carrusel puedes añadir
slides extra.
</p>
<div className="mt-2 flex flex-wrap items-start gap-4">
<label className="relative flex h-36 w-28 cursor-pointer flex-col items-center justify-center overflow-hidden rounded-xl border-2 border-dashed border-gray-300 bg-gray-50 hover:border-brand-500 hover:bg-brand-50/40">
{coverPreview ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={coverPreview}
alt="Portada"
className="h-full w-full object-cover"
/>
) : (
<>
<ImagePlus className="h-7 w-7 text-gray-400" />
<span className="mt-1 px-2 text-center text-[10px] font-medium text-gray-500">
Portada
</span>
</>
)}
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="absolute inset-0 cursor-pointer opacity-0"
onChange={(e) =>
pickCover(e.target.files?.[0] || null)
}
/>
</label>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="mt-1 block w-full text-xs text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-gray-900 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-white hover:file:bg-gray-800"
onChange={(e) => pickSlides(e.target.files)}
/>
{slidePreviews.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{slidePreviews.map((src, i) => (
<div
key={i}
className="relative h-16 w-12 overflow-hidden rounded-lg border border-gray-200"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={`Slide ${i + 1}`}
className="h-full w-full object-cover"
/>
</div>
))}
{coverFile ? (
<div className="flex min-w-0 flex-1 flex-col gap-1 pt-1">
<p className="truncate text-xs font-medium text-gray-800">
{coverFile.name}
</p>
<p className="text-[11px] text-gray-500">
{(coverFile.size / 1024).toFixed(0)} KB
</p>
<button
type="button"
onClick={() => pickCover(null)}
className="w-fit text-xs font-medium text-red-600 hover:underline"
>
Quitar portada
</button>
</div>
) : null}
</div>
) : null}
</div>
{newFormat === 'carrusel' ? (
<div className="mt-3">
<label className="block text-xs font-medium text-gray-600">
Slides del carrusel (opcionales)
</label>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="mt-1 block w-full text-xs text-gray-600 file:mr-3 file:rounded-lg file:border-0 file:bg-gray-900 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-white hover:file:bg-gray-800"
onChange={(e) => pickSlides(e.target.files)}
/>
{slidePreviews.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{slidePreviews.map((src, i) => (
<div
key={i}
className="relative h-16 w-12 overflow-hidden rounded-lg border border-gray-200"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={`Slide ${i + 1}`}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
) : null}
</div>
) : null}
</div>
) : null}
</div>
<label className="flex items-start gap-2 text-xs text-gray-700">
@@ -1113,12 +1216,16 @@ export default function CreativePipelineBoard() {
className="rounded-lg bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-60"
>
{creating
? coverFile
? 'Creando y subiendo creativo…'
: 'Creando…'
: calPick === '__new__'
? 'Crear en pipeline'
: 'Añadir al pipeline (desde calendario)'}
? creationMode === 'auto'
? 'Encolando Claude + Freepik + Canva…'
: coverFile
? 'Creando y subiendo creativo…'
: 'Creando…'
: creationMode === 'auto'
? 'Crear con IA (RRSS)'
: calPick === '__new__'
? 'Crear en pipeline'
: 'Añadir al pipeline (desde calendario)'}
</button>
<button
type="button"
@@ -1260,6 +1367,8 @@ function PieceCard({
badgeCls: 'bg-gray-100 text-gray-700 border-gray-200',
};
const igUrl = getInstagramUrl(item.notes);
const autoGenPending =
Boolean(parseGenerationRequestIdFromNotes(item.notes)) && !item.cover_path;
const status = normalizeApprovalStatus(item.status);
const clientCanDecide = !isAgency && clientCanDecideStatus(status);
@@ -1331,6 +1440,11 @@ function PieceCard({
{slides.length} slides
</span>
)}
{autoGenPending ? (
<span className="rounded-md bg-violet-600/90 px-1.5 py-0.5 text-[10px] font-bold uppercase text-white shadow-sm">
IA en cola
</span>
) : null}
</div>
{/* Botón de vista rápida */}

View File

@@ -109,8 +109,14 @@ export interface GenerationRequestBrief {
client_slug: string;
reason: GenerationRequestReason;
source_creative_id?: string | null;
format: 'estatico' | 'carrusel' | 'video';
/** Primary design ratio (always 4:5 for estático). */
/** ads (default) | rrss — RRSS writes back to creative_pipeline */
channel?: 'ads' | 'rrss';
source_pipeline_id?: string | null;
calendar_piece_id?: string | null;
format: 'estatico' | 'carrusel' | 'video' | 'post' | 'story' | 'reel';
/** Format key for skills/formats/*.md (RRSS may differ from format). */
skill_format?: string;
/** Primary design ratio (always 4:5 for estático ads). */
aspect_ratio?: string;
/** All placements to produce for estático: 4:5, 9:16, 1.91:1. */
aspect_ratios?: string[];
@@ -127,6 +133,13 @@ export interface GenerationRequestBrief {
adset_id?: string | null;
language?: string;
deadline?: string;
/** RRSS editorial context (calendar piece, brandbook, history). */
calendar_piece?: Record<string, unknown> | null;
context?: Record<string, unknown> | null;
write_back?: Record<string, unknown> | null;
notes?: string | null;
title?: string | null;
caption?: string | null;
}
export interface GenerationRequestResult {

190
src/lib/rrss-generation.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* RRSS auto-generation: reuse Ads generation_requests → n8n (Claude + Freepik + Canva)
* with channel=rrss and write-back into creative_pipeline.
*/
export type RrssPipelineFormat = 'post' | 'story' | 'reel' | 'carrusel';
export type RrssPlacement = {
aspect_ratio: string;
role: string;
size: string;
meta_key: string;
canva_template_key?: string;
};
/** Placements per RRSS format (not the Ads triple 4:5+9:16+1.91:1). */
export function rrssBriefAspectFields(format: RrssPipelineFormat): {
aspect_ratio: string;
aspect_ratios: string[];
required_placements: RrssPlacement[];
} {
if (format === 'story') {
const p: RrssPlacement = {
aspect_ratio: '9:16',
role: 'stories',
size: '1080x1920',
meta_key: 'asset_url',
canva_template_key: 'stories_9_16',
};
return { aspect_ratio: '9:16', aspect_ratios: ['9:16'], required_placements: [p] };
}
if (format === 'reel') {
const p: RrssPlacement = {
aspect_ratio: '9:16',
role: 'reel',
size: '1080x1920',
meta_key: 'asset_url',
canva_template_key: 'stories_9_16',
};
return { aspect_ratio: '9:16', aspect_ratios: ['9:16'], required_placements: [p] };
}
if (format === 'carrusel') {
const p: RrssPlacement = {
aspect_ratio: '4:5',
role: 'carousel_slide',
size: '1080x1350',
meta_key: 'asset_url',
canva_template_key: 'foto-con-franja',
};
return {
aspect_ratio: '4:5',
aspect_ratios: ['4:5'],
required_placements: [p],
};
}
// post feed
const p: RrssPlacement = {
aspect_ratio: '4:5',
role: 'feed',
size: '1080x1350',
meta_key: 'asset_url',
canva_template_key: 'foto-con-franja',
};
return { aspect_ratio: '4:5', aspect_ratios: ['4:5'], required_placements: [p] };
}
export function isRrssPipelineFormat(f: unknown): f is RrssPipelineFormat {
return f === 'post' || f === 'story' || f === 'reel' || f === 'carrusel';
}
export function mapRrssFormatToSkillFormat(format: RrssPipelineFormat): string {
return format; // skills/formats/{post,story,reel,carrusel}.md
}
export type RrssCalendarPieceBrief = {
id?: string | null;
title?: string | null;
date?: string | null;
time?: string | null;
format?: string | null;
pillar?: string | null;
hook?: string | null;
body?: string | null;
cta?: string | null;
hashtags?: string | null;
platform?: string | null;
};
export type RrssGenerationBriefInput = {
tenantId: string;
clientSlug: string;
clientName: string;
format: RrssPipelineFormat;
pipelineId: string;
calendarPieceId?: string | null;
title: string;
caption?: string | null;
notes?: string | null;
pillar?: string | null;
month?: string | null;
scheduledAt?: string | null;
calendarPiece?: RrssCalendarPieceBrief | null;
/** Recent pipeline captions/titles for style continuity */
recentPipeline?: Array<{ title: string; format: string; caption?: string | null }>;
/** Brandbook prompt snippet (already compiled or raw fields) */
brandbookPrompt?: string | null;
/** Recent organic posts snapshot */
socialHistory?: unknown;
variantsRequested?: number;
extraNotes?: string | null;
};
export function buildRrssGenerationBrief(input: RrssGenerationBriefInput) {
const aspects = rrssBriefAspectFields(input.format);
const cal = input.calendarPiece || {};
return {
channel: 'rrss' as const,
tenant_id: input.tenantId,
client_id: input.tenantId,
client_slug: input.clientSlug,
client_name: input.clientName,
reason: 'calendario' as const,
source_creative_id: null,
source_pipeline_id: input.pipelineId,
calendar_piece_id: input.calendarPieceId || cal.id || null,
format: input.format,
skill_format: mapRrssFormatToSkillFormat(input.format),
...aspects,
variants_requested: Number(input.variantsRequested || 2),
language: 'es',
title: input.title,
caption: input.caption || null,
pillar: input.pillar || cal.pillar || null,
month: input.month || null,
scheduled_at: input.scheduledAt || null,
calendar_piece: {
id: input.calendarPieceId || cal.id || null,
title: cal.title || input.title,
date: cal.date || null,
time: cal.time || null,
format: cal.format || input.format,
pillar: cal.pillar || input.pillar || null,
hook: cal.hook || null,
body: cal.body || input.caption || null,
cta: cal.cta || null,
hashtags: cal.hashtags || null,
platform: cal.platform || 'instagram',
},
context: {
brandbook_prompt: input.brandbookPrompt || null,
recent_pipeline: (input.recentPipeline || []).slice(0, 12),
social_history: input.socialHistory || null,
},
notes:
[
input.extraNotes,
input.notes,
'Canal RRSS · producir pieza para creative_pipeline (no Meta Ads).',
'Escribir resultado vía POST /api/integrations/creative-pipeline con generation_request_id.',
]
.filter(Boolean)
.join('\n'),
write_back: {
target: 'creative_pipeline',
pipeline_id: input.pipelineId,
endpoint: '/api/integrations/creative-pipeline',
},
};
}
/** Marker stored in pipeline.notes while auto-generation is pending. */
export function generationRequestNoteMarker(grId: string): string {
return `[[generation_request:${grId}]]`;
}
export function parseGenerationRequestIdFromNotes(
notes: string | null | undefined
): string | null {
if (!notes) return null;
const m = notes.match(/\[\[generation_request:([0-9a-f-]{36})\]\]/i);
return m?.[1] || null;
}
export function stripGenerationRequestMarker(notes: string | null | undefined): string {
if (!notes) return '';
return notes
.replace(/\[\[generation_request:[0-9a-f-]{36}\]\]/gi, '')
.replace(/\n{3,}/g, '\n\n')
.trim();
}