diff --git a/skills/formats/carrusel.md b/skills/formats/carrusel.md new file mode 100644 index 0000000..f23f37d --- /dev/null +++ b/skills/formats/carrusel.md @@ -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. diff --git a/skills/formats/post.md b/skills/formats/post.md new file mode 100644 index 0000000..7702b1b --- /dev/null +++ b/skills/formats/post.md @@ -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. diff --git a/skills/formats/reel.md b/skills/formats/reel.md new file mode 100644 index 0000000..08c5484 --- /dev/null +++ b/skills/formats/reel.md @@ -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`. diff --git a/skills/formats/story.md b/skills/formats/story.md new file mode 100644 index 0000000..a1705bb --- /dev/null +++ b/skills/formats/story.md @@ -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`. diff --git a/src/app/api/creative-pipeline/generate/route.ts b/src/app/api/creative-pipeline/generate/route.ts new file mode 100644 index 0000000..5239aeb --- /dev/null +++ b/src/app/api/creative-pipeline/generate/route.ts @@ -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; + 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) + : 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.', + }); +} diff --git a/src/app/api/generation-requests/route.ts b/src/app/api/generation-requests/route.ts index a059dd0..b46bce0 100644 --- a/src/app/api/generation-requests/route.ts +++ b/src/app/api/generation-requests/route.ts @@ -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', { diff --git a/src/app/api/integrations/creative-pipeline/route.ts b/src/app/api/integrations/creative-pipeline/route.ts new file mode 100644 index 0000000..e7f141f --- /dev/null +++ b/src/app/api/integrations/creative-pipeline/route.ts @@ -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 { + 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; + 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 + 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 = { + 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 }); +} diff --git a/src/app/api/integrations/skills/route.ts b/src/app/api/integrations/skills/route.ts index 6b1669d..86ce6e1 100644 --- a/src/app/api/integrations/skills/route.ts +++ b/src/app/api/integrations/skills/route.ts @@ -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 diff --git a/src/components/CreativePipelineBoard.tsx b/src/components/CreativePipelineBoard.tsx index c725c85..1aca5f2 100644 --- a/src/components/CreativePipelineBoard.tsx +++ b/src/components/CreativePipelineBoard.tsx @@ -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 = { 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(null); const [coverPreview, setCoverPreview] = useState(null); const [slideFiles, setSlideFiles] = useState([]); @@ -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() { )} -
- -

- Sube la portada aquí (jpg/png/webp). En carrusel puedes añadir - slides extra. +

+

+ Cómo crear el creativo

-
- - {coverFile ? ( -
-

- {coverFile.name} -

-

- {(coverFile.size / 1024).toFixed(0)} KB -

- -
- ) : null} +
+ +
+

+ {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.'} +

+
- {newFormat === 'carrusel' ? ( -
-
{/* Botón de vista rápida */} diff --git a/src/lib/creatives-types.ts b/src/lib/creatives-types.ts index c4b4e02..e2cbb76 100644 --- a/src/lib/creatives-types.ts +++ b/src/lib/creatives-types.ts @@ -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 | null; + context?: Record | null; + write_back?: Record | null; + notes?: string | null; + title?: string | null; + caption?: string | null; } export interface GenerationRequestResult { diff --git a/src/lib/rrss-generation.ts b/src/lib/rrss-generation.ts new file mode 100644 index 0000000..6d0db75 --- /dev/null +++ b/src/lib/rrss-generation.ts @@ -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(); +}