@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.
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 +369 -172
  20. package/dist/server/index.mjs +352 -173
  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,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
- if (!googleGenAI) {
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: googleGenAI });
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.2, instrucciones } = ctx.request.body;
2884
+ const { titulo, categoria, autor, temperatura = 0.5, instrucciones } = ctx.request.body;
2805
2885
  const promptArticulo = `
2806
- Actúa como un Redactor Técnico Senior experto en tecnología corporativa para la empresa "InterWare"
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
- Tu tarea es generar un artículo de blog en formato JSON estricto, siguiendo el schema proporcionado.
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 (Brand Book):
2811
- - Tono: Profesional, innovador, confiable y empático.
2812
- - Estilo: Español neutro corporativo. Evita coloquialismos excesivos pero no seas robótico.
2813
- - Enfoque: Tecnología, Innovación y Soluciones Empresariales.
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 dentro del HTML:
2818
- 1. <h1>Título del Artículo</h1> (Debe ser impactante y contener la palabra clave principal).
2819
- 2. <p>Introducción</p> (Planteamiento del problema o contexto tecnológico).
2820
- 3. Desarrollo usando <h2> y hasta <h6> para jerarquía.
2821
- 4. Uso de <ul> o <ol> para listar beneficios o características.
2822
- 5. (Opcional) <table> si hay datos comparativos.
2823
- 6. Conclusión o Cierre (Sin subtítulo "Conclusión", intégralo naturalmente).
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
- - Incluir metadatos necesarios para Facebook y LinkedIn
2827
- - og:url: Debe ser "https://interware.com.mx/blog/{slug-generado}"
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 Y DE ESTILO:
2831
- - PROHIBIDO: Emojis, Markdown (no usar **negritas** estilo MD, usar <strong>), bloques de código sin etiqueta <pre>.
2832
- - PROHIBIDO: Frases de "Youtuber" (ej: "Suscríbete", "Deja tu like", "Comenta abajo").
2833
- - PROHIBIDO: Texto introductorio fuera del JSON (ej: "Aquí tienes tu JSON..."). Solo devuelve el objeto JSON.
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 ? `- Título sugerido: "${titulo}"` : ""}
2837
- ${categoria ? `- Categoría/Tema: "${categoria}"` : ""}
2838
- ${instrucciones ? `- Instrucciones extra: "${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-flash",
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: "number",
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 prompt = [
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
- Article Title: ${article.titulo}
2971
- Article Summary: ${article.descripcion}
3056
+ Article Title: ${article.titulo}
3057
+ Article Summary: ${article.descripcion}
2972
3058
 
2973
- Creative Direction (Auto-interpret):
2974
- Analyze the summary above to determine the visual category:
3059
+ Creative Direction (Auto-interpret):
3060
+ Analyze the summary above to determine the visual category:
2975
3061
 
2976
- 1. **Technical Topic (Software, Cloud, Data, Security):**
3062
+ 1. **Technical Topic (Software, Cloud, Data, Security):**
2977
3063
 
2978
- **CRITICAL:** Do NOT generate a central "atom", "sphere", or "floating ball" structure. Avoid the generic "connected dots" cliché.
2979
- If the topic is **TECHNICAL (Software, Data, Cloud)**, you must break the repetitive visual pattern.
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
- **STRICT PROHIBITION:** Do NOT generate the standard "isometric cluster of glass cubes" or "floating square blocks".
3067
+ **STRICT PROHIBITION:** Do NOT generate the standard "isometric cluster of glass cubes" or "floating square blocks".
2982
3068
 
2983
- Instead, strictly choose ONE of the following distinct visual approaches based on the nuance of the text:
3069
+ Instead, strictly choose ONE of the following distinct visual approaches based on the nuance of the text:
2984
3070
 
2985
- * **OPTION A: "The Stream" (Speed & Flow)**
2986
- * *Visuals:* Long-exposure light trails, fiber optic cables, or liquid data streams moving horizontally.
2987
- * *Camera:* **Side profile or Worm's-eye view**.
2988
- * *Vibe:* Fast, motion-blurred, linear. No cubes.
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
- * **OPTION B: "Macro Precision" (Code & Detail)**
2991
- * *Visuals:* Extreme close-up of a single abstract circuit line, a glass edge refraction, or a micro-texture.
2992
- * *Camera:* **Macro lens (f/1.8)** with heavy bokeh.
2993
- * *Vibe:* Minimalist, sharp focus on one detail, blurry background. Abstract.
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
- * **OPTION C: "The Network" (Connectivity)**
2996
- * *Visuals:* A vast, flat constellation of small glowing nodes connected by thin lines (plexus effect).
2997
- * *Camera:* **Top-down satellite view** (90 degrees).
2998
- * *Vibe:* Flat, cartographic, expansive.
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
- * **OPTION D: "Abstract Architecture" (Structure)**
3001
- * *Visuals:* Large, towering monoliths (not small cubes) or massive server rack silhouettes fading into fog.
3002
- * *Camera:* **Low angle looking up** (implying scale).
3003
- * *Vibe:* Monumental, heavy, stable.
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
- 2. **Human/Business Topic (Culture, Leadership, Agile, Management):
3006
- Create a cinematic photography scene with a randomized composition and moment in time.
3007
- Show modern professionals in a clean, contemporary office environment, captured from varying perspectives (back view, side angles, or softly blurred faces to maintain anonymity).
3008
- Vary the number of people, their posture and interaction, the lighting conditions, depth of field, and color grading in each generation.
3009
- Use natural, candid collaboration moments rather than posed scenes. The result should fe.
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
- Brand Identity Guidelines (STRICT):
3012
- - **Primary Background:** Deep Corporate Navy Blue (Hex: #1F2A44). The image must feel predominantly dark blue.
3013
- - **Accent Color:** Vibrant Red (Hex: #E4002B). Use sparingly for a single focal point (e.g., a dot, a notebook, a graphical node).
3014
- - **Tech Glow:** Mint Turquoise (Hex: #00C7B1). Use for subtle lighting edges or digital gradients.
3015
- - **Composition:** Asymmetrical balance. Leave **empty negative space on the RIGHT side** specifically for text overlay.
3016
- - **Prohibition:** Do NOT generate text inside the image. Do NOT use pure black shadows; use the Navy Blue.
3017
- `
3018
- }
3019
- ];
3020
- const ai = await this.geminiConfig();
3021
- const result = await ai.models.generateContent({
3022
- model: "gemini-2.5-flash-image",
3023
- contents: prompt,
3024
- config: {
3025
- responseModalities: ["IMAGE"],
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
- if (!imagePart) {
3039
- throw new Error("No se encontró ninguna imagen en la respuesta del modelo");
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: uploadedFiles[0].id
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: uploadedFiles[0]
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: `https://interware.com.mx/blog/${article.slug}`,
3177
- title: {
3178
- text: article.titulo
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>;