@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.
- 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 +367 -152
- package/dist/server/index.mjs +350 -153
- 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.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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.
|
|
2903
|
+
const { titulo, categoria, autor, temperatura = 0.5, instrucciones } = ctx.request.body;
|
|
2806
2904
|
const promptArticulo = `
|
|
2807
|
-
Actúa como un
|
|
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
|
-
|
|
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 (
|
|
2812
|
-
- Tono:
|
|
2813
|
-
- Estilo:
|
|
2814
|
-
-
|
|
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
|
|
2819
|
-
1. <h1>Título
|
|
2820
|
-
2. <p>
|
|
2821
|
-
3. Desarrollo
|
|
2822
|
-
4.
|
|
2823
|
-
5. (Opcional) <table
|
|
2824
|
-
6.
|
|
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
|
-
-
|
|
2828
|
-
-
|
|
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
|
|
2832
|
-
-
|
|
2833
|
-
-
|
|
2834
|
-
-
|
|
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 ? `-
|
|
2838
|
-
${categoria ? `-
|
|
2839
|
-
${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-
|
|
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: "
|
|
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
|
|
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
|
-
|
|
2972
|
-
|
|
3075
|
+
Article Title: ${article.titulo}
|
|
3076
|
+
Article Summary: ${article.descripcion}
|
|
2973
3077
|
|
|
2974
|
-
|
|
2975
|
-
|
|
3078
|
+
Creative Direction (Auto-interpret):
|
|
3079
|
+
Analyze the summary above to determine the visual category:
|
|
2976
3080
|
|
|
2977
|
-
|
|
3081
|
+
1. **Technical Topic (Software, Cloud, Data, Security):**
|
|
2978
3082
|
|
|
2979
|
-
|
|
2980
|
-
|
|
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
|
-
|
|
3086
|
+
**STRICT PROHIBITION:** Do NOT generate the standard "isometric cluster of glass cubes" or "floating square blocks".
|
|
2983
3087
|
|
|
2984
|
-
|
|
3088
|
+
Instead, strictly choose ONE of the following distinct visual approaches based on the nuance of the text:
|
|
2985
3089
|
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
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
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
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
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
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
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
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
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
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
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
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
|
-
|
|
3040
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
3378
|
+
originalUrl: articleUrl,
|
|
3164
3379
|
title: { text: article.titulo },
|
|
3165
3380
|
description: { text: article.descripcion }
|
|
3166
3381
|
}
|