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