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

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();
}