@interware/content-generator 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/dist/_chunks/{App-Cbkccs2K.mjs → App-ClgJldBv.mjs} +539 -14
- package/dist/_chunks/{App-DCuBRwX8.js → App-CwkLhm0x.js} +538 -13
- package/dist/_chunks/{Settings-DuGbtyWC.js → Settings-BEtewXxk.js} +2 -2
- package/dist/_chunks/{Settings-B60QGKK4.mjs → Settings-Bc5rcMRs.mjs} +2 -2
- package/dist/_chunks/{getTranslation-CacX7ZX5.js → getTranslation-C1jZiBcc.js} +1 -1
- package/dist/_chunks/{getTranslation-B97xbmEl.mjs → getTranslation-D3pA_57E.mjs} +1 -1
- package/dist/_chunks/{index-BVGbP3E-.js → index-D4lanCw0.js} +2 -2
- package/dist/_chunks/{index-B2RuvYul.mjs → index-K1A2opOE.mjs} +2 -2
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/admin/src/components/generate-image/Form.d.ts +15 -0
- package/dist/admin/src/components/generate-video/Form.d.ts +36 -0
- package/dist/admin/src/containers/generate-image/index.d.ts +2 -0
- package/dist/admin/src/containers/generate-video/index.d.ts +2 -0
- package/dist/admin/src/lib/use-cases/images/index.d.ts +2 -0
- package/dist/admin/src/pages/GenerateImage.d.ts +2 -0
- package/dist/admin/src/pages/GenerateVideo.d.ts +2 -0
- package/dist/server/index.js +369 -172
- package/dist/server/index.mjs +352 -173
- package/dist/server/src/controllers/controller.d.ts +2 -0
- package/dist/server/src/controllers/index.d.ts +2 -0
- package/dist/server/src/index.d.ts +21 -0
- package/dist/server/src/services/index.d.ts +19 -0
- package/dist/server/src/services/service.d.ts +20 -4
- package/package.json +1 -1
package/dist/server/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { GoogleGenAI } from "@google/genai";
|
|
1
|
+
import { VideoGenerationReferenceType, Modality, GoogleGenAI } from "@google/genai";
|
|
2
|
+
import * as fs from "fs";
|
|
2
3
|
const PLUGIN_ID = "strapi-content-generator-gemini";
|
|
3
4
|
const bootstrap = ({ strapi }) => {
|
|
4
5
|
strapi.admin.services.permission.actionProvider.registerMany([
|
|
@@ -104,6 +105,12 @@ const controller = ({ strapi }) => {
|
|
|
104
105
|
async generateImageForArticle(ctx) {
|
|
105
106
|
ctx.body = await pluginService().generateImageForArticle(ctx);
|
|
106
107
|
},
|
|
108
|
+
async generateImage(ctx) {
|
|
109
|
+
ctx.body = await pluginService().generateImage(ctx);
|
|
110
|
+
},
|
|
111
|
+
async generateVideo(ctx) {
|
|
112
|
+
ctx.body = await pluginService().generateVideo(ctx);
|
|
113
|
+
},
|
|
107
114
|
async publishToLinkedIn(ctx) {
|
|
108
115
|
ctx.body = await pluginService().publishToLinkedIn(ctx);
|
|
109
116
|
},
|
|
@@ -232,6 +239,24 @@ const admin = [
|
|
|
232
239
|
auth: false
|
|
233
240
|
}
|
|
234
241
|
},
|
|
242
|
+
{
|
|
243
|
+
method: "POST",
|
|
244
|
+
path: "/generate-image-with-config",
|
|
245
|
+
handler: "controller.generateImage",
|
|
246
|
+
config: {
|
|
247
|
+
policies: [],
|
|
248
|
+
auth: false
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
method: "POST",
|
|
253
|
+
path: "/generate-video-with-scenes",
|
|
254
|
+
handler: "controller.generateVideo",
|
|
255
|
+
config: {
|
|
256
|
+
policies: [],
|
|
257
|
+
auth: false
|
|
258
|
+
}
|
|
259
|
+
},
|
|
235
260
|
{
|
|
236
261
|
method: "POST",
|
|
237
262
|
path: "/publish-linkedin",
|
|
@@ -2755,17 +2780,15 @@ const service = ({ strapi }) => {
|
|
|
2755
2780
|
name: "strapi-content-generator-gemini"
|
|
2756
2781
|
});
|
|
2757
2782
|
const googleGenAI = await pluginStore.get({ key: "GOOGLE_GEN_AI" });
|
|
2758
|
-
|
|
2783
|
+
const geminiApiKey = process.env.GOOGLE_API_KEY || googleGenAI;
|
|
2784
|
+
if (!geminiApiKey) {
|
|
2759
2785
|
throw new Error(
|
|
2760
|
-
"La variable de entorno GOOGLE_GEN_AI no está configurada."
|
|
2786
|
+
"La variable de entorno GOOGLE_API_KEY o GOOGLE_GEN_AI no está configurada."
|
|
2761
2787
|
);
|
|
2762
2788
|
}
|
|
2763
|
-
const ai = new GoogleGenAI({ apiKey:
|
|
2789
|
+
const ai = new GoogleGenAI({ apiKey: geminiApiKey });
|
|
2764
2790
|
return ai;
|
|
2765
2791
|
},
|
|
2766
|
-
/**
|
|
2767
|
-
* Fetches articles.
|
|
2768
|
-
*/
|
|
2769
2792
|
async getArticulos(sanitizedQueryParams) {
|
|
2770
2793
|
return await strapi.entityService.findMany("api::articulo.articulo", sanitizedQueryParams);
|
|
2771
2794
|
},
|
|
@@ -2789,7 +2812,8 @@ const service = ({ strapi }) => {
|
|
|
2789
2812
|
$eq: existingFolders[0].id
|
|
2790
2813
|
}
|
|
2791
2814
|
}
|
|
2792
|
-
}
|
|
2815
|
+
},
|
|
2816
|
+
sort: ["createdAt:desc"]
|
|
2793
2817
|
});
|
|
2794
2818
|
}
|
|
2795
2819
|
return [];
|
|
@@ -2798,49 +2822,114 @@ const service = ({ strapi }) => {
|
|
|
2798
2822
|
return await strapi.documents("plugin::upload.file").delete(sanitizedQueryParams);
|
|
2799
2823
|
},
|
|
2800
2824
|
// ───────────────────────────────
|
|
2825
|
+
// 🏞️ Internal File Upload Helper
|
|
2826
|
+
// ───────────────────────────────
|
|
2827
|
+
async _uploadGeneratedFile(file, filename, mimeType, folderName = "Generated") {
|
|
2828
|
+
const existingFolders = await strapi.documents("plugin::upload.folder").findMany({
|
|
2829
|
+
filters: { name: folderName }
|
|
2830
|
+
});
|
|
2831
|
+
let folderId;
|
|
2832
|
+
if (!existingFolders || existingFolders.length === 0) {
|
|
2833
|
+
const lastFolder = await strapi.documents("plugin::upload.folder").findMany({
|
|
2834
|
+
sort: { pathId: "desc" },
|
|
2835
|
+
limit: 1
|
|
2836
|
+
});
|
|
2837
|
+
const pathId = Array.isArray(lastFolder) && lastFolder.length > 0 ? lastFolder[0].pathId + 1 : 1;
|
|
2838
|
+
const createdFolder = await strapi.documents("plugin::upload.folder").create({
|
|
2839
|
+
data: {
|
|
2840
|
+
name: folderName,
|
|
2841
|
+
path: `/${folderName.toLowerCase()}`,
|
|
2842
|
+
pathId
|
|
2843
|
+
}
|
|
2844
|
+
});
|
|
2845
|
+
folderId = createdFolder.id;
|
|
2846
|
+
} else {
|
|
2847
|
+
folderId = existingFolders[0].id;
|
|
2848
|
+
}
|
|
2849
|
+
let fileSize = 0;
|
|
2850
|
+
if (typeof file === "string") {
|
|
2851
|
+
const stats = fs.statSync(file);
|
|
2852
|
+
fileSize = stats.size;
|
|
2853
|
+
}
|
|
2854
|
+
const filesPayload = Buffer.isBuffer(file) ? {
|
|
2855
|
+
filepath: file,
|
|
2856
|
+
originalFilename: filename,
|
|
2857
|
+
mimetype: mimeType
|
|
2858
|
+
} : {
|
|
2859
|
+
filepath: file,
|
|
2860
|
+
originalFilename: filename,
|
|
2861
|
+
mimetype: mimeType,
|
|
2862
|
+
size: fileSize
|
|
2863
|
+
};
|
|
2864
|
+
const uploadedFiles = await strapi.plugins.upload.services.upload.upload({
|
|
2865
|
+
data: {
|
|
2866
|
+
fileInfo: {
|
|
2867
|
+
name: filename,
|
|
2868
|
+
alternativeText: `Generated content: ${filename}`,
|
|
2869
|
+
caption: `AI generated content: ${filename}`,
|
|
2870
|
+
folder: folderId
|
|
2871
|
+
}
|
|
2872
|
+
},
|
|
2873
|
+
files: filesPayload
|
|
2874
|
+
});
|
|
2875
|
+
if (!uploadedFiles || uploadedFiles.length === 0) {
|
|
2876
|
+
throw new Error("La subida del archivo falló.");
|
|
2877
|
+
}
|
|
2878
|
+
return uploadedFiles[0];
|
|
2879
|
+
},
|
|
2880
|
+
// ───────────────────────────────
|
|
2801
2881
|
// 🧠 Generate Article with AI
|
|
2802
2882
|
// ───────────────────────────────
|
|
2803
2883
|
async generateArticle(ctx) {
|
|
2804
|
-
const { titulo, categoria, autor, temperatura = 0.
|
|
2884
|
+
const { titulo, categoria, autor, temperatura = 0.5, instrucciones } = ctx.request.body;
|
|
2805
2885
|
const promptArticulo = `
|
|
2806
|
-
Actúa como un
|
|
2886
|
+
Actúa como un Consultor Tecnológico de Nivel Ejecutivo y Arquitecto de Soluciones para "InterWare".
|
|
2887
|
+
|
|
2888
|
+
Tu tarea es generar un artículo de blog ("Thought Leadership") en formato JSON estricto, siguiendo el schema proporcionado.
|
|
2807
2889
|
|
|
2808
|
-
|
|
2890
|
+
PERFIL Y OBJETIVO:
|
|
2891
|
+
El lector objetivo es un C-Level (CIO, CTO, CEO) o Director de TI en México. No tienen tiempo para narrativas vacías. Buscan:
|
|
2892
|
+
1. Solvencia técnica.
|
|
2893
|
+
2. Impacto en el negocio (ROI, Eficiencia, Escalabilidad).
|
|
2894
|
+
3. Claridad absoluta sin adornos literarios.
|
|
2809
2895
|
|
|
2810
|
-
IDENTIDAD Y TONO DE VOZ (
|
|
2811
|
-
- Tono:
|
|
2812
|
-
- Estilo:
|
|
2813
|
-
-
|
|
2896
|
+
IDENTIDAD Y TONO DE VOZ (Strict Guidelines):
|
|
2897
|
+
- Tono: Autoritario, Pragmático, Directo y Analítico.
|
|
2898
|
+
- Estilo: "Business-First Technical". Usa terminología precisa de industria.
|
|
2899
|
+
- PROHIBIDO: Metáforas (ej: "navegar las aguas", "el ADN de la empresa"), adjetivos emocionales (ej: "apasionante", "maravilloso") o introducciones largas.
|
|
2900
|
+
- Enfoque: Ve directo al grano. Problema de negocio -> Solución Técnica -> Resultado.
|
|
2814
2901
|
|
|
2815
2902
|
ESTRUCTURA DEL CONTENIDO (HTML para CKEditor):
|
|
2816
|
-
- El campo "content" debe contener HTML válido y limpio.
|
|
2817
|
-
- Estructura obligatoria
|
|
2818
|
-
1. <h1>Título
|
|
2819
|
-
2. <p>
|
|
2820
|
-
3. Desarrollo
|
|
2821
|
-
4.
|
|
2822
|
-
5. (Opcional) <table
|
|
2823
|
-
6.
|
|
2903
|
+
- El campo "content" debe contener HTML válido, semántico y limpio.
|
|
2904
|
+
- Estructura obligatoria:
|
|
2905
|
+
1. <h1>Título</h1>: Directo, orientado a beneficio o problema crítico (debe incluir la keyword).
|
|
2906
|
+
2. <p>Lead/Intro</p>: Máximo 3 líneas. Define el desafío técnico/negocio inmediatamente. Sin preguntas retóricas.
|
|
2907
|
+
3. Desarrollo (<h2> - <h6>): Usa jerarquía lógica. Los subtítulos deben ser descriptivos, no creativos.
|
|
2908
|
+
4. Listas (<ul>/<ol>): Úsalas para enumerar especificaciones, pasos técnicos o KPIs.
|
|
2909
|
+
5. (Opcional) <table>: Úsala para datos comparativos
|
|
2910
|
+
6. Cierre: Un párrafo final de síntesis estratégica. No uses el subtítulo "Conclusión". Termina con una visión de futuro o recomendación táctica.
|
|
2824
2911
|
|
|
2825
|
-
SEO:
|
|
2826
|
-
-
|
|
2827
|
-
-
|
|
2828
|
-
- Omitir author, publisher, image y robots
|
|
2912
|
+
SEO Y METADATOS:
|
|
2913
|
+
- og:url: "https://interware.com.mx/blog/{slug-generado-basado-en-titulo}"
|
|
2914
|
+
- Omitir: author, publisher, image, robots.
|
|
2829
2915
|
|
|
2830
|
-
RESTRICCIONES TÉCNICAS
|
|
2831
|
-
-
|
|
2832
|
-
-
|
|
2833
|
-
-
|
|
2916
|
+
RESTRICCIONES TÉCNICAS ABSOLUTAS:
|
|
2917
|
+
- Salida ÚNICA: Objeto JSON crudo. Nada de texto antes ni después.
|
|
2918
|
+
- Formato: No usar Emojis, No usar Markdown (negritas). Usar etiquetas HTML (<strong>, <em>).
|
|
2919
|
+
- Bloques de código: Si son necesarios, envolver estrictamente en <pre><code>...</code></pre>.
|
|
2920
|
+
- Lenguaje: Español Neutro Corporativo (sin regionalismos, sin jerga de marketing vacía).
|
|
2834
2921
|
|
|
2835
2922
|
ENTRADA DE CONTEXTO:
|
|
2836
|
-
${titulo ? `-
|
|
2837
|
-
${categoria ? `-
|
|
2838
|
-
${instrucciones ? `-
|
|
2923
|
+
${titulo ? `- Suggested title: "${titulo}"` : ""}
|
|
2924
|
+
${categoria ? `- Category or topic: "${categoria}"` : ""}
|
|
2925
|
+
${instrucciones ? `- Additional instructions: "${instrucciones}"` : ""}
|
|
2926
|
+
|
|
2927
|
+
Recuerda: Eres un experto hablando con otro experto. Sé denso en valor y conciso en palabras
|
|
2839
2928
|
`;
|
|
2840
2929
|
try {
|
|
2841
2930
|
const ai = await this.geminiConfig();
|
|
2842
2931
|
const result = await ai.models.generateContent({
|
|
2843
|
-
model: "gemini-2.5-
|
|
2932
|
+
model: "gemini-2.5-pro",
|
|
2844
2933
|
contents: promptArticulo,
|
|
2845
2934
|
config: {
|
|
2846
2935
|
responseMimeType: "application/json",
|
|
@@ -2895,7 +2984,7 @@ const service = ({ strapi }) => {
|
|
|
2895
2984
|
description: "Contenido HTML compatible con CKEditor"
|
|
2896
2985
|
},
|
|
2897
2986
|
minute_read: {
|
|
2898
|
-
type: "
|
|
2987
|
+
type: "integer",
|
|
2899
2988
|
minimum: 1,
|
|
2900
2989
|
maximum: 30,
|
|
2901
2990
|
description: "Tiempo de lectura calculado usando 200 palabras por minuto"
|
|
@@ -2924,7 +3013,6 @@ const service = ({ strapi }) => {
|
|
|
2924
3013
|
);
|
|
2925
3014
|
}
|
|
2926
3015
|
const parsed = JSON.parse(rawText);
|
|
2927
|
-
console.log("🚀 ~ service ~ parsed:", parsed);
|
|
2928
3016
|
const article = await strapi.documents("api::articulo.articulo").create({
|
|
2929
3017
|
data: {
|
|
2930
3018
|
titulo: parsed.titulo,
|
|
@@ -2952,7 +3040,7 @@ const service = ({ strapi }) => {
|
|
|
2952
3040
|
// 🖼️ Generate Image for Article
|
|
2953
3041
|
// ───────────────────────────────
|
|
2954
3042
|
async generateImageForArticle(ctx) {
|
|
2955
|
-
const { articleId } = ctx.request.body;
|
|
3043
|
+
const { articleId, aspectRatio, imageSize } = ctx.request.body;
|
|
2956
3044
|
if (!articleId) {
|
|
2957
3045
|
throw new Error(
|
|
2958
3046
|
"Se requiere 'articleId' en el cuerpo de la petición."
|
|
@@ -2962,135 +3050,72 @@ const service = ({ strapi }) => {
|
|
|
2962
3050
|
const article = await strapi.documents("api::articulo.articulo").findOne({
|
|
2963
3051
|
documentId: articleId
|
|
2964
3052
|
});
|
|
2965
|
-
const
|
|
2966
|
-
|
|
2967
|
-
text: `
|
|
2968
|
-
Act as a Corporate Art Director for "InterWare". Generate a high-quality blog header image based on the following article context:
|
|
3053
|
+
const promptText = `
|
|
3054
|
+
Act as a Corporate Art Director for "InterWare". Generate a high-quality blog header image based on the following article context:
|
|
2969
3055
|
|
|
2970
|
-
|
|
2971
|
-
|
|
3056
|
+
Article Title: ${article.titulo}
|
|
3057
|
+
Article Summary: ${article.descripcion}
|
|
2972
3058
|
|
|
2973
|
-
|
|
2974
|
-
|
|
3059
|
+
Creative Direction (Auto-interpret):
|
|
3060
|
+
Analyze the summary above to determine the visual category:
|
|
2975
3061
|
|
|
2976
|
-
|
|
3062
|
+
1. **Technical Topic (Software, Cloud, Data, Security):**
|
|
2977
3063
|
|
|
2978
|
-
|
|
2979
|
-
|
|
3064
|
+
**CRITICAL:** Do NOT generate a central "nodes", "atom", "sphere", "building", "blocks", or "floating ball" structure. Avoid the generic "connected dots" cliché.
|
|
3065
|
+
If the topic is **TECHNICAL (Software, Data, Cloud)**, you must break the repetitive visual pattern.
|
|
2980
3066
|
|
|
2981
|
-
|
|
3067
|
+
**STRICT PROHIBITION:** Do NOT generate the standard "isometric cluster of glass cubes" or "floating square blocks".
|
|
2982
3068
|
|
|
2983
|
-
|
|
3069
|
+
Instead, strictly choose ONE of the following distinct visual approaches based on the nuance of the text:
|
|
2984
3070
|
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
3071
|
+
* **OPTION A: "The Stream" (Speed & Flow)**
|
|
3072
|
+
* *Visuals:* Long-exposure light trails, fiber optic cables, or liquid data streams moving horizontally.
|
|
3073
|
+
* *Camera:* **Side profile or Worm's-eye view**.
|
|
3074
|
+
* *Vibe:* Fast, motion-blurred, linear. No cubes.
|
|
2989
3075
|
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
3076
|
+
* **OPTION B: "Macro Precision" (Code & Detail)**
|
|
3077
|
+
* *Visuals:* Extreme close-up of a single abstract circuit line, a glass edge refraction, or a micro-texture.
|
|
3078
|
+
* *Camera:* **Macro lens (f/1.8)** with heavy bokeh.
|
|
3079
|
+
* *Vibe:* Minimalist, sharp focus on one detail, blurry background. Abstract.
|
|
2994
3080
|
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
3081
|
+
* **OPTION C: "The Network" (Connectivity)**
|
|
3082
|
+
* *Visuals:* A vast, flat constellation of small glowing nodes connected by thin lines (plexus effect).
|
|
3083
|
+
* *Camera:* **Top-down satellite view** (90 degrees).
|
|
3084
|
+
* *Vibe:* Flat, cartographic, expansive.
|
|
2999
3085
|
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3086
|
+
* **OPTION D: "Abstract Architecture" (Structure)**
|
|
3087
|
+
* *Visuals:* Large, towering monoliths (not small cubes) or massive server rack silhouettes fading into fog.
|
|
3088
|
+
* *Camera:* **Low angle looking up** (implying scale).
|
|
3089
|
+
* *Vibe:* Monumental, heavy, stable.
|
|
3004
3090
|
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3091
|
+
2. **Human/Business Topic (Culture, Leadership, Agile, Management):
|
|
3092
|
+
Create a cinematic photography scene with a randomized composition and moment in time.
|
|
3093
|
+
Show modern professionals in a clean, contemporary office environment, captured from varying perspectives (back view, side angles, or softly blurred faces to maintain anonymity).
|
|
3094
|
+
Vary the number of people, their posture and interaction, the lighting conditions, depth of field, and color grading in each generation.
|
|
3095
|
+
Use natural, candid collaboration moments rather than posed scenes. The result should fe.
|
|
3010
3096
|
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
imageConfig: {
|
|
3027
|
-
aspectRatio: "16:9"
|
|
3028
|
-
}
|
|
3029
|
-
}
|
|
3030
|
-
});
|
|
3031
|
-
const parts = result?.candidates?.[0]?.content?.parts;
|
|
3032
|
-
if (!Array.isArray(parts)) {
|
|
3033
|
-
throw new Error("Respuesta inválida del modelo (parts no encontrado)");
|
|
3034
|
-
}
|
|
3035
|
-
const imagePart = parts.find(
|
|
3036
|
-
(p) => typeof p === "object" && p.inlineData && typeof p.inlineData.data === "string" && p.inlineData.mimeType?.startsWith("image/")
|
|
3097
|
+
Brand Identity Guidelines (STRICT):
|
|
3098
|
+
- **Primary Background:** Deep Corporate Navy Blue (Hex: #1F2A44). The image must feel predominantly dark blue.
|
|
3099
|
+
- **Accent Color:** Vibrant Red (Hex: #E4002B). Use sparingly for a single focal point (e.g., a dot, a notebook, a graphical node).
|
|
3100
|
+
- **Tech Glow:** Mint Turquoise (Hex: #00C7B1). Use for subtle lighting edges or digital gradients.
|
|
3101
|
+
- **Composition:** Asymmetrical balance. Leave **empty negative space on the RIGHT side** specifically for text overlay.
|
|
3102
|
+
- **Prohibition:** Do NOT generate text inside the image. Do NOT use pure black shadows; use the Navy Blue.
|
|
3103
|
+
`;
|
|
3104
|
+
const generatedBase64Image = await this._callGeminiImageGeneration(
|
|
3105
|
+
promptText,
|
|
3106
|
+
aspectRatio || "16:9",
|
|
3107
|
+
// Use provided aspectRatio or default
|
|
3108
|
+
imageSize || "1K",
|
|
3109
|
+
// Use provided imageSize or default
|
|
3110
|
+
[]
|
|
3111
|
+
// No reference images for article generation
|
|
3037
3112
|
);
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
}
|
|
3041
|
-
const buffer = Buffer.from(imagePart.inlineData.data, "base64");
|
|
3042
|
-
const existingFolders = await strapi.documents("plugin::upload.folder").findMany({
|
|
3043
|
-
filters: {
|
|
3044
|
-
name: "Generated"
|
|
3045
|
-
}
|
|
3046
|
-
});
|
|
3047
|
-
let folder;
|
|
3048
|
-
let folderPath;
|
|
3049
|
-
if (existingFolders.length === 0) {
|
|
3050
|
-
const folders = await strapi.documents("plugin::upload.folder").findMany(
|
|
3051
|
-
{
|
|
3052
|
-
sort: {
|
|
3053
|
-
pathId: "desc"
|
|
3054
|
-
},
|
|
3055
|
-
limit: 1
|
|
3056
|
-
}
|
|
3057
|
-
);
|
|
3058
|
-
const pathId = Array.isArray(folders) && folders.length > 0 ? folders[0].pathId + 1 : 1;
|
|
3059
|
-
const createdFolder = await strapi.documents("plugin::upload.folder").create({
|
|
3060
|
-
data: {
|
|
3061
|
-
name: "Generated",
|
|
3062
|
-
path: "/generated",
|
|
3063
|
-
pathId
|
|
3064
|
-
}
|
|
3065
|
-
});
|
|
3066
|
-
folder = createdFolder.id;
|
|
3067
|
-
folderPath = createdFolder.path;
|
|
3068
|
-
} else {
|
|
3069
|
-
folder = existingFolders[0].id;
|
|
3070
|
-
folderPath = existingFolders[0].path;
|
|
3071
|
-
}
|
|
3072
|
-
const uploadedFiles = await strapi.plugins.upload.services.upload.upload({
|
|
3073
|
-
data: {
|
|
3074
|
-
fileInfo: {
|
|
3075
|
-
name: `article-${articleId}.png`,
|
|
3076
|
-
alternativeText: `Imagen para el artículo ${article.titulo}`,
|
|
3077
|
-
caption: `Imagen generada por IA para el artículo ${article.titulo}`,
|
|
3078
|
-
folder
|
|
3079
|
-
}
|
|
3080
|
-
},
|
|
3081
|
-
files: {
|
|
3082
|
-
filepath: buffer,
|
|
3083
|
-
originalFilename: `article-${articleId}.png`,
|
|
3084
|
-
mimetype: "image/png"
|
|
3085
|
-
}
|
|
3086
|
-
});
|
|
3087
|
-
if (!uploadedFiles || uploadedFiles.length === 0) {
|
|
3088
|
-
throw new Error("La subida de la imagen falló.");
|
|
3089
|
-
}
|
|
3113
|
+
const buffer = Buffer.from(generatedBase64Image, "base64");
|
|
3114
|
+
const uploadedFile = await this._uploadGeneratedFile(buffer, `article-${articleId}.png`, "image/png");
|
|
3090
3115
|
const articleModel = await strapi.getModel("api::articulo.articulo");
|
|
3091
3116
|
const sanitizedData = await strapi.contentAPI.sanitize.input(
|
|
3092
3117
|
{
|
|
3093
|
-
image:
|
|
3118
|
+
image: uploadedFile.id
|
|
3094
3119
|
},
|
|
3095
3120
|
articleModel
|
|
3096
3121
|
);
|
|
@@ -3100,7 +3125,7 @@ const service = ({ strapi }) => {
|
|
|
3100
3125
|
});
|
|
3101
3126
|
return {
|
|
3102
3127
|
message: "Imagen generada y asociada correctamente.",
|
|
3103
|
-
image:
|
|
3128
|
+
image: uploadedFile
|
|
3104
3129
|
};
|
|
3105
3130
|
} catch (err) {
|
|
3106
3131
|
console.log("🚀 ~ service ~ err:", err);
|
|
@@ -3108,6 +3133,169 @@ const service = ({ strapi }) => {
|
|
|
3108
3133
|
}
|
|
3109
3134
|
},
|
|
3110
3135
|
// ───────────────────────────────
|
|
3136
|
+
// 🖼️ Internal Gemini Image Generation Helper
|
|
3137
|
+
// ───────────────────────────────
|
|
3138
|
+
async _callGeminiImageGeneration(promptText, aspectRatio, imageSizeLabel, referenceImages) {
|
|
3139
|
+
const ai = await this.geminiConfig();
|
|
3140
|
+
const cleanPrompt = promptText.replace(/\r\n/g, " ").replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
|
3141
|
+
const imageParts = referenceImages.map((img) => ({
|
|
3142
|
+
inlineData: {
|
|
3143
|
+
mimeType: img.mimeType,
|
|
3144
|
+
data: img.buffer.toString("base64")
|
|
3145
|
+
}
|
|
3146
|
+
}));
|
|
3147
|
+
const contents = [
|
|
3148
|
+
{ text: cleanPrompt },
|
|
3149
|
+
...imageParts
|
|
3150
|
+
];
|
|
3151
|
+
const result = await ai.models.generateContent({
|
|
3152
|
+
model: "gemini-2.5-flash-image",
|
|
3153
|
+
contents,
|
|
3154
|
+
config: {
|
|
3155
|
+
responseModalities: [Modality.IMAGE],
|
|
3156
|
+
imageConfig: {
|
|
3157
|
+
aspectRatio,
|
|
3158
|
+
imageSize: imageSizeLabel
|
|
3159
|
+
}
|
|
3160
|
+
}
|
|
3161
|
+
});
|
|
3162
|
+
const parts = result?.candidates?.[0]?.content?.parts;
|
|
3163
|
+
if (!Array.isArray(parts)) {
|
|
3164
|
+
throw new Error("Respuesta inválida del modelo (parts no encontrado)");
|
|
3165
|
+
}
|
|
3166
|
+
const imagePart = parts.find(
|
|
3167
|
+
(p) => p?.inlineData?.data && typeof p.inlineData.data === "string" && p.inlineData.mimeType?.startsWith("image/")
|
|
3168
|
+
);
|
|
3169
|
+
if (!imagePart) {
|
|
3170
|
+
throw new Error("No se encontró ninguna imagen en la respuesta del modelo");
|
|
3171
|
+
}
|
|
3172
|
+
return imagePart.inlineData.data;
|
|
3173
|
+
},
|
|
3174
|
+
// ───────────────────────────────
|
|
3175
|
+
// 🖼️ Generate Image (Standalone)
|
|
3176
|
+
// ───────────────────────────────
|
|
3177
|
+
async generateImage(ctx) {
|
|
3178
|
+
const { prompt, aspectRatio, imageSize } = ctx.request.body;
|
|
3179
|
+
if (!prompt) {
|
|
3180
|
+
throw new Error("Se requiere 'prompt' para generar el contenido visual.");
|
|
3181
|
+
}
|
|
3182
|
+
const files = ctx.request.files ?? {};
|
|
3183
|
+
const referenceImages = Object.entries(files).filter(([key]) => key.startsWith("referenceImages")).map(([, file]) => file);
|
|
3184
|
+
if (referenceImages.length > 5) {
|
|
3185
|
+
throw new Error("Máximo 5 imágenes de referencia permitidas.");
|
|
3186
|
+
}
|
|
3187
|
+
const imageBuffers = referenceImages.map((file) => {
|
|
3188
|
+
const typedFile = file;
|
|
3189
|
+
return {
|
|
3190
|
+
buffer: typedFile.buffer ?? fs.readFileSync(typedFile.filepath),
|
|
3191
|
+
mimeType: typedFile.mimetype
|
|
3192
|
+
};
|
|
3193
|
+
});
|
|
3194
|
+
try {
|
|
3195
|
+
const generatedBase64Image = await this._callGeminiImageGeneration(
|
|
3196
|
+
prompt,
|
|
3197
|
+
aspectRatio || "1:1",
|
|
3198
|
+
imageSize || "1K",
|
|
3199
|
+
imageBuffers
|
|
3200
|
+
);
|
|
3201
|
+
const buffer = Buffer.from(generatedBase64Image, "base64");
|
|
3202
|
+
const filename = `generated-image-${Date.now()}.png`;
|
|
3203
|
+
const uploadedFile = await this._uploadGeneratedFile(buffer, filename, "image/png");
|
|
3204
|
+
return {
|
|
3205
|
+
message: `Imagen generada y subida correctamente.`,
|
|
3206
|
+
file: uploadedFile
|
|
3207
|
+
};
|
|
3208
|
+
} catch (err) {
|
|
3209
|
+
console.error("🚀 ~ generateImage ~ err:", err);
|
|
3210
|
+
throw new Error(`Error al generar la imagen.`);
|
|
3211
|
+
}
|
|
3212
|
+
},
|
|
3213
|
+
// 🎞️ Internal Veo Video Generation / Extension Helper
|
|
3214
|
+
async _callVeoVideoGeneration(promptText, aspectRatio, durationSeconds, sceneRefs, previousVideo) {
|
|
3215
|
+
const ai = await this.geminiConfig();
|
|
3216
|
+
const cleanPrompt = promptText.replace(/\s+/g, " ").trim();
|
|
3217
|
+
const referenceImages = sceneRefs.map((img) => ({
|
|
3218
|
+
image: {
|
|
3219
|
+
imageBytes: img.buffer.toString("base64"),
|
|
3220
|
+
mimeType: img.mimeType
|
|
3221
|
+
},
|
|
3222
|
+
referenceType: VideoGenerationReferenceType.ASSET
|
|
3223
|
+
}));
|
|
3224
|
+
const config2 = {
|
|
3225
|
+
numberOfVideos: 1,
|
|
3226
|
+
resolution: "720p",
|
|
3227
|
+
aspectRatio,
|
|
3228
|
+
durationSeconds,
|
|
3229
|
+
referenceImages: referenceImages.length > 0 ? referenceImages : void 0
|
|
3230
|
+
};
|
|
3231
|
+
const operationParams = {
|
|
3232
|
+
model: "veo-3.1-generate-preview",
|
|
3233
|
+
prompt: cleanPrompt,
|
|
3234
|
+
config: config2
|
|
3235
|
+
};
|
|
3236
|
+
if (previousVideo) {
|
|
3237
|
+
operationParams.video = previousVideo;
|
|
3238
|
+
}
|
|
3239
|
+
let operation = await ai.models.generateVideos(operationParams);
|
|
3240
|
+
while (!operation.done) {
|
|
3241
|
+
await new Promise((r) => setTimeout(r, 1e4));
|
|
3242
|
+
operation = await ai.operations.getVideosOperation({ operation });
|
|
3243
|
+
}
|
|
3244
|
+
const videoInfo = operation.response.generatedVideos?.[0];
|
|
3245
|
+
if (!videoInfo) throw new Error("Veo no devolvió video.");
|
|
3246
|
+
return videoInfo.video;
|
|
3247
|
+
},
|
|
3248
|
+
async generateVideo(ctx) {
|
|
3249
|
+
const { aspectRatio = "16:9", scenes } = ctx.request.body;
|
|
3250
|
+
if (!Array.isArray(scenes) || !scenes.length) {
|
|
3251
|
+
throw new Error("Se requiere al menos una escena.");
|
|
3252
|
+
}
|
|
3253
|
+
const files = ctx.request.files ?? {};
|
|
3254
|
+
const referenceImagesMap = {};
|
|
3255
|
+
Object.entries(files).forEach(([key, file]) => {
|
|
3256
|
+
const match = key.match(/scenes\[(\d+)\]\[referenceImages\]\[\d+\]/);
|
|
3257
|
+
if (!match) return;
|
|
3258
|
+
const sceneIndex = Number(match[1]);
|
|
3259
|
+
const typedFile = file;
|
|
3260
|
+
if (!referenceImagesMap[sceneIndex]) {
|
|
3261
|
+
referenceImagesMap[sceneIndex] = [];
|
|
3262
|
+
}
|
|
3263
|
+
referenceImagesMap[sceneIndex].push({
|
|
3264
|
+
buffer: typedFile.buffer ?? fs.readFileSync(typedFile.filepath),
|
|
3265
|
+
mimeType: typedFile.mimetype
|
|
3266
|
+
});
|
|
3267
|
+
});
|
|
3268
|
+
let previousVideo = null;
|
|
3269
|
+
for (const [i, scene] of scenes.entries()) {
|
|
3270
|
+
const durationSeconds = Number(scene.durationSeconds);
|
|
3271
|
+
const sceneRefs = referenceImagesMap[i] || [];
|
|
3272
|
+
const video = await this._callVeoVideoGeneration(
|
|
3273
|
+
scene.prompt,
|
|
3274
|
+
aspectRatio,
|
|
3275
|
+
durationSeconds,
|
|
3276
|
+
sceneRefs,
|
|
3277
|
+
previousVideo || void 0
|
|
3278
|
+
);
|
|
3279
|
+
previousVideo = video;
|
|
3280
|
+
}
|
|
3281
|
+
if (!previousVideo) {
|
|
3282
|
+
throw new Error("No se generó ningún video.");
|
|
3283
|
+
}
|
|
3284
|
+
const finalVideoPath = `/tmp/video-${Date.now()}.mp4`;
|
|
3285
|
+
const ai = await this.geminiConfig();
|
|
3286
|
+
await ai.files.download({ file: previousVideo, downloadPath: finalVideoPath });
|
|
3287
|
+
const uploadedFile = await this._uploadGeneratedFile(
|
|
3288
|
+
finalVideoPath,
|
|
3289
|
+
`video-${Date.now()}.mp4`,
|
|
3290
|
+
"video/mp4"
|
|
3291
|
+
);
|
|
3292
|
+
fs.unlinkSync(finalVideoPath);
|
|
3293
|
+
return {
|
|
3294
|
+
message: "Video final generado con audio correctamente.",
|
|
3295
|
+
file: uploadedFile
|
|
3296
|
+
};
|
|
3297
|
+
},
|
|
3298
|
+
// ───────────────────────────────
|
|
3111
3299
|
// 🌐 Social Media Publishing
|
|
3112
3300
|
// ───────────────────────────────
|
|
3113
3301
|
async publishToLinkedIn(ctx) {
|
|
@@ -3143,6 +3331,15 @@ const service = ({ strapi }) => {
|
|
|
3143
3331
|
(keyword) => `#${keyword.name.trim().replace(/\s+/g, "").replace(/[^\w]/g, "")}`
|
|
3144
3332
|
// limpia caracteres raros
|
|
3145
3333
|
).join(" ");
|
|
3334
|
+
const articleUrl = `https://interware.com.mx/blog/${article.slug}`;
|
|
3335
|
+
await axios.get(articleUrl, {
|
|
3336
|
+
headers: {
|
|
3337
|
+
"User-Agent": "LinkedInBot"
|
|
3338
|
+
},
|
|
3339
|
+
timeout: 5e3
|
|
3340
|
+
}).catch((err) => {
|
|
3341
|
+
strapi.log.warn("Advertencia: No se pudo cachear la URL en LinkedIn antes de publicar.", err.message);
|
|
3342
|
+
});
|
|
3146
3343
|
const postBody = {
|
|
3147
3344
|
author: `urn:li:organization:${linkedInOrganizationId}`,
|
|
3148
3345
|
lifecycleState: "PUBLISHED",
|
|
@@ -3157,29 +3354,11 @@ ${hashtags}`
|
|
|
3157
3354
|
},
|
|
3158
3355
|
shareMediaCategory: "ARTICLE",
|
|
3159
3356
|
media: [
|
|
3160
|
-
// Imagen del artículo
|
|
3161
|
-
...article.image ? [
|
|
3162
|
-
{
|
|
3163
|
-
status: "READY",
|
|
3164
|
-
description: {
|
|
3165
|
-
text: article.descripcion
|
|
3166
|
-
},
|
|
3167
|
-
media: `https://api.interware.com.mx${article.image.formats.small.url}`,
|
|
3168
|
-
title: {
|
|
3169
|
-
text: article.titulo
|
|
3170
|
-
}
|
|
3171
|
-
}
|
|
3172
|
-
] : [],
|
|
3173
|
-
// URL del artículo
|
|
3174
3357
|
{
|
|
3175
3358
|
status: "READY",
|
|
3176
|
-
originalUrl:
|
|
3177
|
-
title: {
|
|
3178
|
-
|
|
3179
|
-
},
|
|
3180
|
-
description: {
|
|
3181
|
-
text: article.descripcion
|
|
3182
|
-
}
|
|
3359
|
+
originalUrl: articleUrl,
|
|
3360
|
+
title: { text: article.titulo },
|
|
3361
|
+
description: { text: article.descripcion }
|
|
3183
3362
|
}
|
|
3184
3363
|
]
|
|
3185
3364
|
}
|
|
@@ -9,6 +9,8 @@ declare const controller: ({ strapi }: {
|
|
|
9
9
|
deleteImage(ctx: any): Promise<unknown>;
|
|
10
10
|
generateArticle(ctx: any): Promise<void>;
|
|
11
11
|
generateImageForArticle(ctx: any): Promise<void>;
|
|
12
|
+
generateImage(ctx: any): Promise<void>;
|
|
13
|
+
generateVideo(ctx: any): Promise<void>;
|
|
12
14
|
publishToLinkedIn(ctx: any): Promise<void>;
|
|
13
15
|
publishToFacebook(ctx: any): Promise<void>;
|
|
14
16
|
get(ctx: any): Promise<void>;
|