@runnerpro/backend 1.22.7 → 1.23.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/lib/cjs/chat/api/conversation.js +5 -4
- package/lib/cjs/index.js +5 -1
- package/lib/cjs/mediaProcessing/audio.js +216 -0
- package/lib/cjs/mediaProcessing/documents.js +225 -0
- package/lib/cjs/mediaProcessing/ffmpegUtils.js +107 -0
- package/lib/cjs/mediaProcessing/image.js +59 -0
- package/lib/cjs/mediaProcessing/index.js +145 -132
- package/lib/cjs/mediaProcessing/video.js +149 -0
- package/lib/cjs/prompt/ai.js +29 -1
- package/lib/cjs/types/chat/api/conversation.d.ts.map +1 -1
- package/lib/cjs/types/index.d.ts +2 -2
- package/lib/cjs/types/index.d.ts.map +1 -1
- package/lib/cjs/types/mediaProcessing/audio.d.ts +63 -0
- package/lib/cjs/types/mediaProcessing/audio.d.ts.map +1 -0
- package/lib/cjs/types/mediaProcessing/documents.d.ts +79 -0
- package/lib/cjs/types/mediaProcessing/documents.d.ts.map +1 -0
- package/lib/cjs/types/mediaProcessing/ffmpegUtils.d.ts +31 -0
- package/lib/cjs/types/mediaProcessing/ffmpegUtils.d.ts.map +1 -0
- package/lib/cjs/types/mediaProcessing/image.d.ts +20 -0
- package/lib/cjs/types/mediaProcessing/image.d.ts.map +1 -0
- package/lib/cjs/types/mediaProcessing/index.d.ts +41 -28
- package/lib/cjs/types/mediaProcessing/index.d.ts.map +1 -1
- package/lib/cjs/types/mediaProcessing/video.d.ts +21 -0
- package/lib/cjs/types/mediaProcessing/video.d.ts.map +1 -0
- package/lib/cjs/types/prompt/ai.d.ts.map +1 -1
- package/lib/cjs/types/workout/planificacionPrueba7dias/empezarCorrer/getPlanificacionPrueba7diasEmpezarCorrer.d.ts.map +1 -1
- package/lib/cjs/types/workout/planificacionPrueba7dias/index.d.ts.map +1 -1
- package/lib/cjs/workout/planificacionPrueba7dias/empezarCorrer/getPlanificacionPrueba7diasEmpezarCorrer.js +8 -0
- package/lib/cjs/workout/planificacionPrueba7dias/index.js +17 -3
- package/package.json +1 -1
|
@@ -508,6 +508,11 @@ const sendFile = (req, res, { sendNotification, firebaseMessaging, isClient, buc
|
|
|
508
508
|
files.push({ data: fileData, id: idFile });
|
|
509
509
|
}
|
|
510
510
|
res.send({ idFile, logrosDesbloqueados });
|
|
511
|
+
// Procesar el adjunto en background, sea del tipo que sea: transcripción, descripción, resumen
|
|
512
|
+
// o ficha del archivo. El router de mediaProcessing decide qué toca. Va ANTES de subir a Storage
|
|
513
|
+
// a propósito: si la subida falla, el mensaje se quedaría sin FILE TEXT de forma permanente y
|
|
514
|
+
// eso bloquea al entrenador, que no distingue "sin texto" de "aún sin procesar".
|
|
515
|
+
(0, mediaProcessing_1.processMediaFile)(idFile, fileData, req.file.mimetype, req.file.originalname);
|
|
511
516
|
for (const file of files) {
|
|
512
517
|
yield bucket.file(`Chat/${file.id}`).save(file.data);
|
|
513
518
|
}
|
|
@@ -530,10 +535,6 @@ const sendFile = (req, res, { sendNotification, firebaseMessaging, isClient, buc
|
|
|
530
535
|
});
|
|
531
536
|
yield updateSenderView({ userid, idCliente, idMessage: idFile });
|
|
532
537
|
}
|
|
533
|
-
// Procesar archivo multimedia en background (transcripción de audio / descripción de imagen)
|
|
534
|
-
if (req.file.mimetype.startsWith('audio/') || req.file.mimetype.startsWith('image/')) {
|
|
535
|
-
(0, mediaProcessing_1.processMediaFile)(idFile, fileData, req.file.mimetype);
|
|
536
|
-
}
|
|
537
538
|
});
|
|
538
539
|
const getThumbnailFromVideo = (videoPath, duration) => __awaiter(void 0, void 0, void 0, function* () {
|
|
539
540
|
const targetWidth = 500;
|
package/lib/cjs/index.js
CHANGED
|
@@ -24,7 +24,7 @@ var __importStar = (this && this.__importStar) || function (mod) {
|
|
|
24
24
|
};
|
|
25
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
26
|
exports.describeImage = exports.transcribeAudio = exports.updateSenderView = exports.saveResponseTime = exports.getZone = exports.saveDoneStructuraWorkout = exports.computeStreakStats = exports.getUnlockedAchievements = exports.evaluateAchievementsBounded = exports.evaluateAchievements = exports.saveWorkoutLaps = exports.saveWorkoutAplication = exports.getPlanificacionPrueba7dias = exports.sendWorkoutToWatch = exports.getDefaultWorkoutImage = exports.generateShareMap = exports.reduceSizeImage = exports.getLetter = exports.getNumberByLetter = exports.appendSheet = exports.writeSheet = exports.findCellByValue = exports.readSheet = exports.NOTION_DATABASES_ID = exports.notionEditPage = exports.notionAddPage = exports.notionGetDatabase = exports.notionGetUsers = exports.getCountNotificaciones = exports.chatExposed = exports.chatApi = exports.chat = exports.getExerciseTranslatedDescription = exports.useTranslation = exports.LANGUAGES = exports.translate = exports.CHANNEL_SLACK = exports.notifySlack = exports.fetchIA = exports.err = exports.sendMail = exports.pool = exports.toPgArray = exports.batchQuery = exports.longRunningQuery = exports.queryWithClient = exports.query = exports.sleep = exports.sendNotification = exports.achievementsCatalog = void 0;
|
|
27
|
-
exports.calculateCost = exports.MODEL_PRICING = exports.AISTUDIO_PREFIX = exports.BEDROCK_CLAUDE_HAIKU = exports.BEDROCK_CLAUDE_SONNET_4 = exports.BEDROCK_CLAUDE_SONNET_4_5 = exports.BEDROCK_CLAUDE_OPUS_4_1 = exports.BEDROCK_CLAUDE_OPUS_4_5 = exports.BEDROCK_CLAUDE_SONNET = exports.BEDROCK_CLAUDE_OPUS = exports.FALLBACK_MODELS = exports.PRODUCTION_MODELS = exports.AZURE_PRIMARY_MODELS = exports.AZURE_GPT_5_4_NANO = exports.AZURE_GPT_5_4_MINI = exports.AZURE_GPT_5_5 = exports.AZURE_GPT_5_4_NANO_DEPLOYMENT = exports.AZURE_GPT_5_4_MINI_DEPLOYMENT = exports.AZURE_GPT_5_5_DEPLOYMENT = exports.AZURE_PREFIX = exports.GOOGLE_MODELS = exports.AI_MODELS = exports.MODEL_TIER = exports.runWithModels = exports.runWithCostTracking = exports.createModelFromString = exports.createAzureModelFromString = exports.createBedrockModelFromString = exports.createGoogleModelFromString = exports.createGoogleModel = exports.generateText = exports.generateObject = exports.reprocessRecentMedia = exports.processMediaFile = void 0;
|
|
27
|
+
exports.calculateCost = exports.MODEL_PRICING = exports.AISTUDIO_PREFIX = exports.BEDROCK_CLAUDE_HAIKU = exports.BEDROCK_CLAUDE_SONNET_4 = exports.BEDROCK_CLAUDE_SONNET_4_5 = exports.BEDROCK_CLAUDE_OPUS_4_1 = exports.BEDROCK_CLAUDE_OPUS_4_5 = exports.BEDROCK_CLAUDE_SONNET = exports.BEDROCK_CLAUDE_OPUS = exports.FALLBACK_MODELS = exports.PRODUCTION_MODELS = exports.AZURE_PRIMARY_MODELS = exports.AZURE_GPT_5_4_NANO = exports.AZURE_GPT_5_4_MINI = exports.AZURE_GPT_5_5 = exports.AZURE_GPT_5_4_NANO_DEPLOYMENT = exports.AZURE_GPT_5_4_MINI_DEPLOYMENT = exports.AZURE_GPT_5_5_DEPLOYMENT = exports.AZURE_PREFIX = exports.GOOGLE_MODELS = exports.AI_MODELS = exports.MODEL_TIER = exports.runWithModels = exports.runWithCostTracking = exports.createModelFromString = exports.createAzureModelFromString = exports.createBedrockModelFromString = exports.createGoogleModelFromString = exports.createGoogleModel = exports.generateText = exports.generateObject = exports.reprocessRecentMedia = exports.processMediaFile = exports.analyzeFile = exports.describeTextFile = exports.describePdf = exports.describeVideo = void 0;
|
|
28
28
|
const sendNotification_1 = require("./sendNotification");
|
|
29
29
|
Object.defineProperty(exports, "sendNotification", { enumerable: true, get: function () { return sendNotification_1.sendNotification; } });
|
|
30
30
|
const sleep_1 = require("./sleep");
|
|
@@ -102,6 +102,10 @@ Object.defineProperty(exports, "updateSenderView", { enumerable: true, get: func
|
|
|
102
102
|
const mediaProcessing_1 = require("./mediaProcessing");
|
|
103
103
|
Object.defineProperty(exports, "transcribeAudio", { enumerable: true, get: function () { return mediaProcessing_1.transcribeAudio; } });
|
|
104
104
|
Object.defineProperty(exports, "describeImage", { enumerable: true, get: function () { return mediaProcessing_1.describeImage; } });
|
|
105
|
+
Object.defineProperty(exports, "describeVideo", { enumerable: true, get: function () { return mediaProcessing_1.describeVideo; } });
|
|
106
|
+
Object.defineProperty(exports, "describePdf", { enumerable: true, get: function () { return mediaProcessing_1.describePdf; } });
|
|
107
|
+
Object.defineProperty(exports, "describeTextFile", { enumerable: true, get: function () { return mediaProcessing_1.describeTextFile; } });
|
|
108
|
+
Object.defineProperty(exports, "analyzeFile", { enumerable: true, get: function () { return mediaProcessing_1.analyzeFile; } });
|
|
105
109
|
Object.defineProperty(exports, "processMediaFile", { enumerable: true, get: function () { return mediaProcessing_1.processMediaFile; } });
|
|
106
110
|
Object.defineProperty(exports, "reprocessRecentMedia", { enumerable: true, get: function () { return mediaProcessing_1.reprocessRecentMedia; } });
|
|
107
111
|
const prompt_1 = require("./prompt");
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.SYNC_MAX_SECONDS = exports.AUDIO_ENCODING_MAP = exports.convertToWav16k = exports.transcribeWithModel = exports.transcribeWav16k = exports.transcribeAudio = void 0;
|
|
16
|
+
const fs_1 = __importDefault(require("fs"));
|
|
17
|
+
const path_1 = __importDefault(require("path"));
|
|
18
|
+
const speech_1 = require("@google-cloud/speech");
|
|
19
|
+
const ffmpegUtils_1 = require("./ffmpegUtils");
|
|
20
|
+
const index_1 = require("../prompt/index");
|
|
21
|
+
const constants_1 = require("../prompt/constants");
|
|
22
|
+
const index_2 = require("../err/index");
|
|
23
|
+
// ✅ Mapeo de MIME types a encoding de Speech-to-Text. Solo se usa en el camino de emergencia,
|
|
24
|
+
// cuando ffmpeg no ha podido convertir el archivo y hay que mandarlo tal cual.
|
|
25
|
+
const AUDIO_ENCODING_MAP = {
|
|
26
|
+
'audio/mpeg': 'MP3',
|
|
27
|
+
'audio/mp3': 'MP3',
|
|
28
|
+
'audio/wav': 'LINEAR16',
|
|
29
|
+
'audio/wave': 'LINEAR16',
|
|
30
|
+
'audio/x-wav': 'LINEAR16',
|
|
31
|
+
'audio/ogg': 'OGG_OPUS',
|
|
32
|
+
'audio/webm': 'WEBM_OPUS',
|
|
33
|
+
'audio/flac': 'FLAC',
|
|
34
|
+
'audio/x-flac': 'FLAC',
|
|
35
|
+
'audio/mp4': 'MP3',
|
|
36
|
+
'audio/m4a': 'MP3',
|
|
37
|
+
'audio/aac': 'MP3',
|
|
38
|
+
};
|
|
39
|
+
exports.AUDIO_ENCODING_MAP = AUDIO_ENCODING_MAP;
|
|
40
|
+
// ✅ `recognize` (síncrono) rechaza audio de más de 60s. Cuando se le declara LINEAR16 deduce la
|
|
41
|
+
// duración exacta de los bytes y lo aplica a rajatabla, así que por encima de esto no se le manda.
|
|
42
|
+
const SYNC_MAX_SECONDS = 55;
|
|
43
|
+
exports.SYNC_MAX_SECONDS = SYNC_MAX_SECONDS;
|
|
44
|
+
// ✅ Speech-to-Text admite 10 MB en línea; a 32 KB/s son ~290s. Gemini aguanta mucho más,
|
|
45
|
+
// pero recortamos para acotar el coste y la latencia de una nota de voz.
|
|
46
|
+
const TRANSCRIPTION_MAX_SECONDS = 600;
|
|
47
|
+
const TRANSCRIPTION_PROMPT = `Transcribe literalmente lo que se dice en este audio. Es una nota de voz que un corredor manda a su entrenador.
|
|
48
|
+
Devuelve SOLO la transcripción en su idioma original, sin comentarios, sin introducción y sin describir el audio.
|
|
49
|
+
Si de verdad no se oye ninguna voz, responde exactamente: SIN_VOZ`;
|
|
50
|
+
/**
|
|
51
|
+
* Llama a Speech-to-Text con un buffer y una configuración ya decididas
|
|
52
|
+
*
|
|
53
|
+
* @param content - Buffer del audio
|
|
54
|
+
* @param encoding - Encoding declarado (LINEAR16, MP3...)
|
|
55
|
+
* @returns Promise<string | null> - Transcripción, o null si no se reconoció nada
|
|
56
|
+
*/
|
|
57
|
+
const recognizeSpeech = (content, encoding) => __awaiter(void 0, void 0, void 0, function* () {
|
|
58
|
+
var _a;
|
|
59
|
+
try {
|
|
60
|
+
const speechClient = new speech_1.SpeechClient();
|
|
61
|
+
// @ts-ignore - Tipos de Speech-to-Text son complejos
|
|
62
|
+
const [response] = yield speechClient.recognize({
|
|
63
|
+
audio: {
|
|
64
|
+
content: content.toString('base64'),
|
|
65
|
+
},
|
|
66
|
+
config: {
|
|
67
|
+
// @ts-ignore - El encoding se determina dinámicamente
|
|
68
|
+
encoding: encoding,
|
|
69
|
+
sampleRateHertz: ffmpegUtils_1.SPEECH_SAMPLE_RATE,
|
|
70
|
+
languageCode: 'es-ES', // Español de España
|
|
71
|
+
alternativeLanguageCodes: ['es-MX', 'es-AR', 'en-US'], // Alternativas: México, Argentina, Inglés
|
|
72
|
+
enableAutomaticPunctuation: true, // Puntuación automática
|
|
73
|
+
model: 'latest_long', // Modelo más reciente para audios largos
|
|
74
|
+
useEnhanced: true, // Modelo mejorado
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const transcription = (_a = response.results) === null || _a === void 0 ? void 0 : _a.map((result) => { var _a, _b; return (_b = (_a = result.alternatives) === null || _a === void 0 ? void 0 : _a[0]) === null || _b === void 0 ? void 0 : _b.transcript; }).filter(Boolean).join(' ').trim();
|
|
78
|
+
return transcription || null;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
// eslint-disable-next-line no-console
|
|
82
|
+
console.error('[transcribeAudio] Error Speech-to-Text:', (error === null || error === void 0 ? void 0 : error.message) || error);
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
/**
|
|
87
|
+
* Transcribe con un modelo multimodal de Google. Es la red que recoge lo que Speech-to-Text
|
|
88
|
+
* no reconoce y, sobre todo, los audios de más de 60s, que el modo síncrono no admite.
|
|
89
|
+
*
|
|
90
|
+
* @param wavBuffer - Buffer WAV mono 16 kHz
|
|
91
|
+
* @returns Promise<string | null> - Transcripción, o null si no hay voz
|
|
92
|
+
*/
|
|
93
|
+
const transcribeWithModel = (wavBuffer) => __awaiter(void 0, void 0, void 0, function* () {
|
|
94
|
+
try {
|
|
95
|
+
const model = (0, index_1.createGoogleModelFromString)(constants_1.GOOGLE_MODELS.FLASH);
|
|
96
|
+
// El primario es Vertex; si el proyecto no lo tiene disponible se pasa a AI Studio
|
|
97
|
+
model._fallbackModelNames = [`${constants_1.AISTUDIO_PREFIX}${constants_1.GOOGLE_MODELS.FLASH}`, 'gemini-2.5-flash', `${constants_1.AISTUDIO_PREFIX}gemini-2.5-flash`];
|
|
98
|
+
const { text } = yield (0, index_1.generateText)({
|
|
99
|
+
model,
|
|
100
|
+
// ⭐ Sin registro en LLM CACHE: el audio no aporta nada como clave y engorda la tabla
|
|
101
|
+
log: false,
|
|
102
|
+
messages: [
|
|
103
|
+
{
|
|
104
|
+
role: 'user',
|
|
105
|
+
content: [
|
|
106
|
+
{ type: 'file', data: wavBuffer, mediaType: 'audio/wav' },
|
|
107
|
+
{ type: 'text', text: TRANSCRIPTION_PROMPT },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
const transcription = (text || '').trim();
|
|
113
|
+
return !transcription || transcription === 'SIN_VOZ' ? null : transcription;
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
// eslint-disable-next-line no-console
|
|
117
|
+
console.error('[transcribeAudio] Error transcribiendo con el modelo:', (error === null || error === void 0 ? void 0 : error.message) || error);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
exports.transcribeWithModel = transcribeWithModel;
|
|
122
|
+
/**
|
|
123
|
+
* Convierte cualquier audio al formato que espera Speech-to-Text (WAV PCM mono 16 kHz)
|
|
124
|
+
*
|
|
125
|
+
* @param fileBuffer - Buffer del audio original
|
|
126
|
+
* @param filename - Nombre original del archivo
|
|
127
|
+
* @returns WAV mono 16 kHz y la duración original en segundos
|
|
128
|
+
*/
|
|
129
|
+
const convertToWav16k = (fileBuffer, filename) => (0, ffmpegUtils_1.withTempDir)((dir) => __awaiter(void 0, void 0, void 0, function* () {
|
|
130
|
+
// ffmpeg detecta el formato por contenido, así que la extensión solo es cosmética
|
|
131
|
+
const extension = path_1.default.extname(filename || '') || '.m4a';
|
|
132
|
+
const inputPath = path_1.default.join(dir, `input${extension}`);
|
|
133
|
+
const outputPath = path_1.default.join(dir, 'audio.wav');
|
|
134
|
+
fs_1.default.writeFileSync(inputPath, fileBuffer);
|
|
135
|
+
const { duration } = yield (0, ffmpegUtils_1.probeMedia)(inputPath);
|
|
136
|
+
yield (0, ffmpegUtils_1.runFfmpeg)((0, ffmpegUtils_1.ffmpeg)(inputPath)
|
|
137
|
+
.noVideo()
|
|
138
|
+
.audioCodec('pcm_s16le')
|
|
139
|
+
.audioFrequency(ffmpegUtils_1.SPEECH_SAMPLE_RATE)
|
|
140
|
+
.audioChannels(1)
|
|
141
|
+
.duration(TRANSCRIPTION_MAX_SECONDS)
|
|
142
|
+
.format('wav')
|
|
143
|
+
.output(outputPath), 'conversión de audio');
|
|
144
|
+
return { wav: fs_1.default.readFileSync(outputPath), duration };
|
|
145
|
+
}));
|
|
146
|
+
exports.convertToWav16k = convertToWav16k;
|
|
147
|
+
/**
|
|
148
|
+
* Transcribe una nota de voz.
|
|
149
|
+
*
|
|
150
|
+
* El audio se normaliza a WAV mono 16 kHz y luego se elige motor por duración:
|
|
151
|
+
* - hasta 55s → Speech-to-Text, que es barato y preciso.
|
|
152
|
+
* - más de 55s → modelo multimodal, porque `recognize` no admite audios más largos y
|
|
153
|
+
* `longRunningRecognize` exige subir el archivo a GCS antes.
|
|
154
|
+
* - si Speech-to-Text no reconoce nada, se reintenta con el modelo antes de darlo por perdido.
|
|
155
|
+
*
|
|
156
|
+
* ⚠️ El orden importa y no es intercambiable. Medido sobre notas de voz reales de producción
|
|
157
|
+
* que hoy acaban en '[Audio sin contenido reconocible]':
|
|
158
|
+
* - mandar el AAC crudo declarando 'MP3' (lo que se hacía): 0 de 14 recuperadas.
|
|
159
|
+
* - convertir a WAV y declarar LINEAR16: 13 de 14 (la que falla es de 81s).
|
|
160
|
+
* - modelo multimodal: 14 de 14.
|
|
161
|
+
* Y a la inversa, convertir a WAV SIN respetar el límite de 55s rompe los audios largos que hoy
|
|
162
|
+
* sí funcionan (4 de 4 medidos): con LINEAR16 la API deduce la duración exacta y los rechaza,
|
|
163
|
+
* mientras que con un códec comprimido declarado se saltaba esa comprobación.
|
|
164
|
+
*
|
|
165
|
+
* @param fileBuffer - Buffer del archivo de audio
|
|
166
|
+
* @param mimetype - Tipo MIME del archivo (ej: 'audio/aac', 'audio/x-m4a')
|
|
167
|
+
* @param filename - Nombre original del archivo
|
|
168
|
+
* @returns Promise<string> - Transcripción del audio
|
|
169
|
+
*
|
|
170
|
+
* @example
|
|
171
|
+
* ```typescript
|
|
172
|
+
* const transcription = await transcribeAudio(audioBuffer, 'audio/aac', 'audio.aac');
|
|
173
|
+
* // Resultado: "Hola, quería comentarte que hoy me duele un poco la rodilla..."
|
|
174
|
+
* ```
|
|
175
|
+
*/
|
|
176
|
+
const transcribeAudio = (fileBuffer, mimetype, filename) => __awaiter(void 0, void 0, void 0, function* () {
|
|
177
|
+
let wav;
|
|
178
|
+
let duration;
|
|
179
|
+
try {
|
|
180
|
+
({ wav, duration } = yield convertToWav16k(fileBuffer, filename));
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
// ⭐ Sin ffmpeg no se puede elegir motor: se manda el original tal cual, como se hacía antes
|
|
184
|
+
// eslint-disable-next-line no-console
|
|
185
|
+
console.error('[transcribeAudio] No se pudo convertir el audio, se envía sin normalizar:', (error === null || error === void 0 ? void 0 : error.message) || error);
|
|
186
|
+
(0, index_2.err)(null, null, error, null);
|
|
187
|
+
const mime = (mimetype || '').toLowerCase().split(';')[0].trim();
|
|
188
|
+
return (yield recognizeSpeech(fileBuffer, AUDIO_ENCODING_MAP[mime] || 'MP3')) || '[Audio sin contenido reconocible]';
|
|
189
|
+
}
|
|
190
|
+
if (duration > 0 && duration <= SYNC_MAX_SECONDS) {
|
|
191
|
+
const transcription = yield recognizeSpeech(wav, 'LINEAR16');
|
|
192
|
+
if (transcription)
|
|
193
|
+
return transcription;
|
|
194
|
+
}
|
|
195
|
+
const fromModel = yield transcribeWithModel(wav);
|
|
196
|
+
if (fromModel) {
|
|
197
|
+
return duration > TRANSCRIPTION_MAX_SECONDS
|
|
198
|
+
? `${fromModel} (Solo se transcribieron los primeros ${Math.round(TRANSCRIPTION_MAX_SECONDS / 60)} minutos.)`
|
|
199
|
+
: fromModel;
|
|
200
|
+
}
|
|
201
|
+
return '[Audio sin contenido reconocible]';
|
|
202
|
+
});
|
|
203
|
+
exports.transcribeAudio = transcribeAudio;
|
|
204
|
+
/**
|
|
205
|
+
* Transcribe un WAV que ya viene en el formato que espera Speech-to-Text
|
|
206
|
+
* (PCM 16 bits, mono, 16 kHz) y dura menos de 55 segundos. Lo usa el análisis de vídeo,
|
|
207
|
+
* que extrae la pista así y ya la recorta.
|
|
208
|
+
*
|
|
209
|
+
* @param wavBuffer - Buffer WAV mono 16 kHz de menos de 55 segundos
|
|
210
|
+
* @returns Promise<string> - Transcripción del audio
|
|
211
|
+
*/
|
|
212
|
+
const transcribeWav16k = (wavBuffer) => __awaiter(void 0, void 0, void 0, function* () {
|
|
213
|
+
const transcription = (yield recognizeSpeech(wavBuffer, 'LINEAR16')) || (yield transcribeWithModel(wavBuffer));
|
|
214
|
+
return transcription || '[Audio sin contenido reconocible]';
|
|
215
|
+
});
|
|
216
|
+
exports.transcribeWav16k = transcribeWav16k;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.MAX_MODEL_FILE_BYTES = exports.formatBytes = exports.looksLikeText = exports.decodeTextBuffer = exports.describeOpaqueFile = exports.describeTextFile = exports.describePdf = void 0;
|
|
13
|
+
const index_1 = require("../prompt/index");
|
|
14
|
+
const index_2 = require("../err/index");
|
|
15
|
+
// ✅ Límite de tamaño para mandar un documento entero al modelo (por encima, solo ficha)
|
|
16
|
+
const MAX_MODEL_FILE_BYTES = 15 * 1024 * 1024;
|
|
17
|
+
exports.MAX_MODEL_FILE_BYTES = MAX_MODEL_FILE_BYTES;
|
|
18
|
+
// ✅ Cuánto texto plano se le pasa al modelo (~10k tokens); el resto se recorta
|
|
19
|
+
const MAX_TEXT_CHARS = 40000;
|
|
20
|
+
// ✅ Por debajo de este tamaño, el texto se guarda tal cual en vez de resumirlo
|
|
21
|
+
const VERBATIM_TEXT_CHARS = 2000;
|
|
22
|
+
const PDF_DESCRIPTION_PROMPT = `Resume este documento en un solo párrafo continuo, sin introducciones ni listas.
|
|
23
|
+
Si es contenido deportivo o de salud (informe médico, prueba de esfuerzo, analítica, plan de entrenamiento, inscripción a una carrera, factura de material, resultados, etc.), indica de qué tipo de documento se trata y extrae los datos concretos que importan para entender el estado o el entrenamiento del atleta: fechas, diagnósticos, valores medidos con sus unidades, distancias, tiempos, recomendaciones y restricciones.
|
|
24
|
+
Si NO es deportivo, resume su contenido de forma general pero útil para entender el contexto de la conversación.
|
|
25
|
+
Devuelve ÚNICAMENTE el resumen en español, sin comentarios adicionales. Máximo 400 palabras.`;
|
|
26
|
+
const TEXT_SUMMARY_PROMPT = `Resume el contenido de este archivo en un solo párrafo continuo, sin introducciones ni listas.
|
|
27
|
+
Indica de qué tipo de archivo se trata y extrae los datos concretos que importan para entender el estado o el entrenamiento del atleta: fechas, distancias, tiempos, ritmos, frecuencias cardíacas, desniveles, valores medidos con sus unidades.
|
|
28
|
+
Si el archivo es una traza GPS o un registro de actividad, resume el recorrido y sus métricas agregadas en vez de enumerar puntos.
|
|
29
|
+
Devuelve ÚNICAMENTE el resumen en español, sin comentarios adicionales. Máximo 300 palabras.`;
|
|
30
|
+
// ✅ Nombres legibles para los tipos que no se pueden abrir, para que la ficha diga algo útil
|
|
31
|
+
const MIME_LABELS = {
|
|
32
|
+
'application/zip': 'archivo comprimido ZIP',
|
|
33
|
+
'application/x-zip-compressed': 'archivo comprimido ZIP',
|
|
34
|
+
'application/x-rar-compressed': 'archivo comprimido RAR',
|
|
35
|
+
'application/x-7z-compressed': 'archivo comprimido 7z',
|
|
36
|
+
'application/gzip': 'archivo comprimido GZIP',
|
|
37
|
+
'application/msword': 'documento de Word (.doc)',
|
|
38
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'documento de Word (.docx)',
|
|
39
|
+
'application/vnd.ms-excel': 'hoja de cálculo de Excel (.xls)',
|
|
40
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'hoja de cálculo de Excel (.xlsx)',
|
|
41
|
+
'application/vnd.ms-powerpoint': 'presentación de PowerPoint (.ppt)',
|
|
42
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'presentación de PowerPoint (.pptx)',
|
|
43
|
+
'application/vnd.oasis.opendocument.text': 'documento OpenDocument',
|
|
44
|
+
'application/vnd.ant.fit': 'archivo de actividad FIT del reloj',
|
|
45
|
+
'application/octet-stream': 'archivo binario',
|
|
46
|
+
'text/vcard': 'contacto (vCard)',
|
|
47
|
+
'text/x-vcard': 'contacto (vCard)',
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Formatea un tamaño en bytes de forma legible en español (1024 → "1 KB")
|
|
51
|
+
*
|
|
52
|
+
* @param bytes - Tamaño en bytes
|
|
53
|
+
* @returns Tamaño legible con unidad
|
|
54
|
+
*/
|
|
55
|
+
const formatBytes = (bytes) => {
|
|
56
|
+
if (!bytes || bytes < 1024)
|
|
57
|
+
return `${bytes || 0} B`;
|
|
58
|
+
const units = ['KB', 'MB', 'GB'];
|
|
59
|
+
let value = bytes / 1024;
|
|
60
|
+
let unitIdx = 0;
|
|
61
|
+
while (value >= 1024 && unitIdx < units.length - 1) {
|
|
62
|
+
value /= 1024;
|
|
63
|
+
unitIdx++;
|
|
64
|
+
}
|
|
65
|
+
return `${value.toFixed(1).replace('.', ',').replace(',0', '')} ${units[unitIdx]}`;
|
|
66
|
+
};
|
|
67
|
+
exports.formatBytes = formatBytes;
|
|
68
|
+
/**
|
|
69
|
+
* Heurística para saber si un buffer es texto plano legible.
|
|
70
|
+
* Útil cuando el cliente manda un .gpx o un .csv como 'application/octet-stream'.
|
|
71
|
+
*
|
|
72
|
+
* @param fileBuffer - Buffer del archivo
|
|
73
|
+
* @returns true si los primeros KB parecen texto UTF-8
|
|
74
|
+
*/
|
|
75
|
+
const looksLikeText = (fileBuffer) => {
|
|
76
|
+
const sample = fileBuffer.subarray(0, 4096);
|
|
77
|
+
if (!sample.length)
|
|
78
|
+
return false;
|
|
79
|
+
let printable = 0;
|
|
80
|
+
for (const byte of sample) {
|
|
81
|
+
if (byte === 0)
|
|
82
|
+
return false; // ⭐ un byte nulo descarta texto plano
|
|
83
|
+
// Imprimibles ASCII + tabulador/salto de línea/retorno, o cualquier byte alto (UTF-8 multibyte)
|
|
84
|
+
if (byte >= 32 || byte === 9 || byte === 10 || byte === 13)
|
|
85
|
+
printable++;
|
|
86
|
+
}
|
|
87
|
+
return printable / sample.length > 0.9;
|
|
88
|
+
};
|
|
89
|
+
exports.looksLikeText = looksLikeText;
|
|
90
|
+
/**
|
|
91
|
+
* Decodifica un buffer de texto respetando su BOM.
|
|
92
|
+
*
|
|
93
|
+
* Importa porque los .txt y .csv guardados como "Unicode" desde el Bloc de notas o Excel vienen
|
|
94
|
+
* en UTF-16: leerlos como UTF-8 intercala bytes nulos, y Postgres rechaza el carácter nulo en una
|
|
95
|
+
* columna text, así que el UPDATE de FILE TEXT fallaría (en silencio, porque query() se traga el
|
|
96
|
+
* error) y el adjunto se quedaría sin texto para siempre.
|
|
97
|
+
*
|
|
98
|
+
* @param fileBuffer - Buffer del archivo
|
|
99
|
+
* @returns Contenido decodificado
|
|
100
|
+
*/
|
|
101
|
+
const decodeTextBuffer = (fileBuffer) => {
|
|
102
|
+
// UTF-16 LE
|
|
103
|
+
if (fileBuffer.length >= 2 && fileBuffer[0] === 0xff && fileBuffer[1] === 0xfe) {
|
|
104
|
+
return fileBuffer.subarray(2).toString('utf16le');
|
|
105
|
+
}
|
|
106
|
+
// UTF-16 BE: Node solo sabe leer LE, así que se le da la vuelta a cada par de bytes
|
|
107
|
+
if (fileBuffer.length >= 2 && fileBuffer[0] === 0xfe && fileBuffer[1] === 0xff) {
|
|
108
|
+
const body = Buffer.from(fileBuffer.subarray(2, fileBuffer.length - ((fileBuffer.length - 2) % 2)));
|
|
109
|
+
body.swap16();
|
|
110
|
+
return body.toString('utf16le');
|
|
111
|
+
}
|
|
112
|
+
// BOM UTF-8
|
|
113
|
+
if (fileBuffer.length >= 3 && fileBuffer[0] === 0xef && fileBuffer[1] === 0xbb && fileBuffer[2] === 0xbf) {
|
|
114
|
+
return fileBuffer.subarray(3).toString('utf8');
|
|
115
|
+
}
|
|
116
|
+
return fileBuffer.toString('utf8');
|
|
117
|
+
};
|
|
118
|
+
exports.decodeTextBuffer = decodeTextBuffer;
|
|
119
|
+
/**
|
|
120
|
+
* Genera un resumen de un PDF mandándoselo entero al modelo, que lo lee de forma nativa
|
|
121
|
+
*
|
|
122
|
+
* @param fileBuffer - Buffer del PDF
|
|
123
|
+
* @param filename - Nombre original del archivo (ayuda al modelo a contextualizar)
|
|
124
|
+
* @returns Promise<string> - Resumen del documento
|
|
125
|
+
*
|
|
126
|
+
* @example
|
|
127
|
+
* ```typescript
|
|
128
|
+
* const summary = await describePdf(pdfBuffer, 'informe-traumatologo.pdf');
|
|
129
|
+
* // Resultado: "Informe de traumatología del 12/03 con diagnóstico de fascitis plantar..."
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
const describePdf = (fileBuffer, filename) => __awaiter(void 0, void 0, void 0, function* () {
|
|
133
|
+
try {
|
|
134
|
+
const { text } = yield (0, index_1.generateText)({
|
|
135
|
+
model: (0, index_1.createGoogleModel)('PRO'),
|
|
136
|
+
messages: [
|
|
137
|
+
{
|
|
138
|
+
role: 'user',
|
|
139
|
+
content: [
|
|
140
|
+
{ type: 'file', data: fileBuffer, mediaType: 'application/pdf', filename: filename || 'documento.pdf' },
|
|
141
|
+
{ type: 'text', text: PDF_DESCRIPTION_PROMPT },
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
});
|
|
146
|
+
return text || '[Documento no analizable]';
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
// eslint-disable-next-line no-console
|
|
150
|
+
console.error('[describePdf] Error:', (error === null || error === void 0 ? void 0 : error.message) || error);
|
|
151
|
+
(0, index_2.err)(null, null, error, null);
|
|
152
|
+
return '[Error al analizar documento]';
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
exports.describePdf = describePdf;
|
|
156
|
+
/**
|
|
157
|
+
* Extrae el contenido de un archivo de texto (txt, csv, gpx, json, xml...).
|
|
158
|
+
* Los archivos cortos se guardan literales; los largos se resumen con el modelo.
|
|
159
|
+
*
|
|
160
|
+
* @param fileBuffer - Buffer del archivo
|
|
161
|
+
* @param filename - Nombre original del archivo
|
|
162
|
+
* @returns Promise<string> - Contenido literal o resumen
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```typescript
|
|
166
|
+
* const content = await describeTextFile(gpxBuffer, 'ruta-domingo.gpx');
|
|
167
|
+
* // Resultado: "Traza GPS de una carrera de 12,4 km con 340 m de desnivel..."
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
const describeTextFile = (fileBuffer, filename) => __awaiter(void 0, void 0, void 0, function* () {
|
|
171
|
+
const content = decodeTextBuffer(fileBuffer).trim();
|
|
172
|
+
if (!content)
|
|
173
|
+
return '[Archivo de texto vacío]';
|
|
174
|
+
// ⭐ Si cabe entero y es corto, no gastamos una llamada al modelo
|
|
175
|
+
if (content.length <= VERBATIM_TEXT_CHARS) {
|
|
176
|
+
return filename ? `Contenido de ${filename}: ${content}` : content;
|
|
177
|
+
}
|
|
178
|
+
const truncated = content.slice(0, MAX_TEXT_CHARS);
|
|
179
|
+
const wasTruncated = content.length > MAX_TEXT_CHARS;
|
|
180
|
+
try {
|
|
181
|
+
const { text } = yield (0, index_1.generateText)({
|
|
182
|
+
model: (0, index_1.createGoogleModel)('PRO'),
|
|
183
|
+
prompt: `${TEXT_SUMMARY_PROMPT}
|
|
184
|
+
|
|
185
|
+
Nombre del archivo: ${filename || 'desconocido'}
|
|
186
|
+
${wasTruncated ? `(Contenido recortado a los primeros ${MAX_TEXT_CHARS} caracteres de ${content.length}.)` : ''}
|
|
187
|
+
|
|
188
|
+
--- CONTENIDO ---
|
|
189
|
+
${truncated}`,
|
|
190
|
+
});
|
|
191
|
+
return text || '[Archivo de texto no analizable]';
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
// eslint-disable-next-line no-console
|
|
195
|
+
console.error('[describeTextFile] Error:', (error === null || error === void 0 ? void 0 : error.message) || error);
|
|
196
|
+
(0, index_2.err)(null, null, error, null);
|
|
197
|
+
// ⭐ Si el modelo falla, al menos guardamos el principio del archivo
|
|
198
|
+
return `Contenido de ${filename || 'archivo de texto'} (recortado): ${content.slice(0, VERBATIM_TEXT_CHARS)}`;
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
exports.describeTextFile = describeTextFile;
|
|
202
|
+
/**
|
|
203
|
+
* Ficha determinista para los archivos cuyo contenido no se puede extraer
|
|
204
|
+
* (comprimidos, binarios propietarios, ofimática antigua...).
|
|
205
|
+
*
|
|
206
|
+
* No llama a ningún modelo: su valor es que FILE TEXT nunca se quede a NULL, de forma
|
|
207
|
+
* que el entrenador sabe que hay un adjunto y de qué tipo, en vez de quedarse bloqueado.
|
|
208
|
+
*
|
|
209
|
+
* @param mimetype - Tipo MIME del archivo
|
|
210
|
+
* @param filename - Nombre original del archivo
|
|
211
|
+
* @param size - Tamaño en bytes
|
|
212
|
+
* @returns Ficha del archivo en español
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```typescript
|
|
216
|
+
* describeOpaqueFile('application/zip', 'fotos.zip', 2515000);
|
|
217
|
+
* // "[Archivo adjunto: fotos.zip (archivo comprimido ZIP, 2,4 MB). No se puede leer su contenido automáticamente.]"
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
const describeOpaqueFile = (mimetype, filename, size) => {
|
|
221
|
+
const label = MIME_LABELS[mimetype] || mimetype || 'tipo desconocido';
|
|
222
|
+
const name = filename || 'sin nombre';
|
|
223
|
+
return `[Archivo adjunto: ${name} (${label}, ${formatBytes(size || 0)}). No se puede leer su contenido automáticamente.]`;
|
|
224
|
+
};
|
|
225
|
+
exports.describeOpaqueFile = describeOpaqueFile;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
12
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.SPEECH_SAMPLE_RATE = exports.FFMPEG_TIMEOUT_MS = exports.withTempDir = exports.probeMedia = exports.runFfmpeg = exports.ffmpeg = void 0;
|
|
16
|
+
const fs_1 = __importDefault(require("fs"));
|
|
17
|
+
const os_1 = __importDefault(require("os"));
|
|
18
|
+
const path_1 = __importDefault(require("path"));
|
|
19
|
+
const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg"));
|
|
20
|
+
exports.ffmpeg = fluent_ffmpeg_1.default;
|
|
21
|
+
const ffmpeg_static_1 = __importDefault(require("ffmpeg-static"));
|
|
22
|
+
const ffprobe_static_1 = __importDefault(require("ffprobe-static"));
|
|
23
|
+
fluent_ffmpeg_1.default.setFfmpegPath(ffmpeg_static_1.default);
|
|
24
|
+
fluent_ffmpeg_1.default.setFfprobePath(ffprobe_static_1.default.path);
|
|
25
|
+
// ✅ Techo por si ffmpeg se cuelga: sin él la promesa nunca se resuelve, el finally no llega
|
|
26
|
+
// a borrar el temporal (que en Cloud Run vive en RAM) y el adjunto se queda sin FILE TEXT
|
|
27
|
+
const FFMPEG_TIMEOUT_MS = 120000;
|
|
28
|
+
exports.FFMPEG_TIMEOUT_MS = FFMPEG_TIMEOUT_MS;
|
|
29
|
+
// ✅ Formato que espera Speech-to-Text: WAV PCM 16 bits, mono, 16 kHz
|
|
30
|
+
const SPEECH_SAMPLE_RATE = 16000;
|
|
31
|
+
exports.SPEECH_SAMPLE_RATE = SPEECH_SAMPLE_RATE;
|
|
32
|
+
/**
|
|
33
|
+
* Ejecuta un comando de ffmpeg como promesa, matándolo si se pasa del tiempo máximo
|
|
34
|
+
*
|
|
35
|
+
* @param command - Comando de fluent-ffmpeg ya configurado
|
|
36
|
+
* @param label - Etiqueta para el mensaje de error
|
|
37
|
+
*/
|
|
38
|
+
const runFfmpeg = (command, label) => new Promise((resolve, reject) => {
|
|
39
|
+
let settled = false;
|
|
40
|
+
const finish = (fn) => {
|
|
41
|
+
if (settled)
|
|
42
|
+
return;
|
|
43
|
+
settled = true;
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
fn();
|
|
46
|
+
};
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
finish(() => {
|
|
49
|
+
try {
|
|
50
|
+
command.kill('SIGKILL');
|
|
51
|
+
}
|
|
52
|
+
catch (_a) {
|
|
53
|
+
// el proceso ya no está; da igual
|
|
54
|
+
}
|
|
55
|
+
reject(new Error(`${label}: ffmpeg superó ${FFMPEG_TIMEOUT_MS / 1000}s`));
|
|
56
|
+
});
|
|
57
|
+
}, FFMPEG_TIMEOUT_MS);
|
|
58
|
+
command.on('end', () => finish(resolve)).on('error', (error) => finish(() => reject(error))).run();
|
|
59
|
+
});
|
|
60
|
+
exports.runFfmpeg = runFfmpeg;
|
|
61
|
+
/**
|
|
62
|
+
* Lee duración y pistas disponibles de un archivo multimedia con ffprobe
|
|
63
|
+
*
|
|
64
|
+
* @param inputPath - Ruta del archivo en disco
|
|
65
|
+
* @returns Duración en segundos y si tiene pista de vídeo y de audio
|
|
66
|
+
*/
|
|
67
|
+
const probeMedia = (inputPath) => new Promise((resolve, reject) => {
|
|
68
|
+
// ⭐ Si ffprobe no contesta, la promesa debe rechazar igualmente para que el finally
|
|
69
|
+
// llegue a borrar el temporal
|
|
70
|
+
const timer = setTimeout(() => reject(new Error(`ffprobe superó ${FFMPEG_TIMEOUT_MS / 1000}s`)), FFMPEG_TIMEOUT_MS);
|
|
71
|
+
fluent_ffmpeg_1.default.ffprobe(inputPath, (error, data) => {
|
|
72
|
+
var _a;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
if (error)
|
|
75
|
+
return reject(error);
|
|
76
|
+
const streams = (data === null || data === void 0 ? void 0 : data.streams) || [];
|
|
77
|
+
resolve({
|
|
78
|
+
duration: Number((_a = data === null || data === void 0 ? void 0 : data.format) === null || _a === void 0 ? void 0 : _a.duration) || 0,
|
|
79
|
+
hasAudio: streams.some((stream) => stream.codec_type === 'audio'),
|
|
80
|
+
hasVideo: streams.some((stream) => stream.codec_type === 'video'),
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
exports.probeMedia = probeMedia;
|
|
85
|
+
/**
|
|
86
|
+
* Crea un directorio temporal, ejecuta el trabajo y lo borra pase lo que pase.
|
|
87
|
+
* En Cloud Run el temporal vive en RAM, así que dejarlo sin borrar cuesta memoria.
|
|
88
|
+
*
|
|
89
|
+
* @param work - Función que recibe la ruta del directorio temporal
|
|
90
|
+
* @returns Lo que devuelva `work`
|
|
91
|
+
*/
|
|
92
|
+
// eslint-disable-next-line no-unused-vars -- el nombre del parámetro es obligatorio en un tipo función
|
|
93
|
+
const withTempDir = (work) => __awaiter(void 0, void 0, void 0, function* () {
|
|
94
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'rp-media-'));
|
|
95
|
+
try {
|
|
96
|
+
return yield work(dir);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
try {
|
|
100
|
+
fs_1.default.rmSync(dir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
catch (_a) {
|
|
103
|
+
// El temporal se queda; no merece romper el procesado
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
exports.withTempDir = withTempDir;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
3
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
4
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
5
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
6
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
7
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
8
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
|
+
exports.IMAGE_DESCRIPTION_PROMPT = exports.describeImage = void 0;
|
|
13
|
+
const index_1 = require("../prompt/index");
|
|
14
|
+
const index_2 = require("../err/index");
|
|
15
|
+
// ✅ PROMPT PARA DESCRIPCIÓN DE IMÁGENES
|
|
16
|
+
const IMAGE_DESCRIPTION_PROMPT = `Describe esta imagen de forma directa y completa en un solo párrafo continuo, sin introducciones ni listas.
|
|
17
|
+
Si la imagen es de contenido deportivo (captura de Garmin/Strava/reloj deportivo, foto de lesión/dolor, gráfica de entrenamiento, selfie deportivo, foto de equipamiento, datos de entrenamiento, etc.), incluye el tipo de imagen, los datos relevantes si es una captura (ritmo, distancia, frecuencia cardíaca, zonas, etc.), y cualquier contexto útil para entender el estado del atleta o su entrenamiento.
|
|
18
|
+
Si la imagen NO es deportiva, describe su contenido de forma general pero útil para entender el contexto de la conversación.
|
|
19
|
+
Devuelve ÚNICAMENTE la descripción en español, sin comentarios adicionales ni formato de lista. Máximo 300 palabras.`;
|
|
20
|
+
exports.IMAGE_DESCRIPTION_PROMPT = IMAGE_DESCRIPTION_PROMPT;
|
|
21
|
+
/**
|
|
22
|
+
* Genera una descripción de una imagen usando el modelo de visión (Vercel AI SDK)
|
|
23
|
+
* Contextualizada para una aplicación de fitness/entrenamiento
|
|
24
|
+
*
|
|
25
|
+
* @param fileBuffer - Buffer del archivo de imagen
|
|
26
|
+
* @param mimetype - Tipo MIME del archivo (ej: 'image/jpeg', 'image/png')
|
|
27
|
+
* @param promptOverride - Prompt alternativo (ej: para fotogramas extraídos de un vídeo)
|
|
28
|
+
* @returns Promise<string> - Descripción de la imagen
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* const description = await describeImage(imageBuffer, 'image/jpeg');
|
|
33
|
+
* // Resultado: "Captura de Garmin Connect mostrando un entrenamiento de 10km..."
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
const describeImage = (fileBuffer, mimetype, promptOverride) => __awaiter(void 0, void 0, void 0, function* () {
|
|
37
|
+
try {
|
|
38
|
+
const { text } = yield (0, index_1.generateText)({
|
|
39
|
+
model: (0, index_1.createGoogleModel)('PRO'),
|
|
40
|
+
messages: [
|
|
41
|
+
{
|
|
42
|
+
role: 'user',
|
|
43
|
+
content: [
|
|
44
|
+
{ type: 'file', data: fileBuffer, mediaType: mimetype },
|
|
45
|
+
{ type: 'text', text: promptOverride || IMAGE_DESCRIPTION_PROMPT },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
return text || '[Imagen no analizable]';
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// eslint-disable-next-line no-console
|
|
54
|
+
console.error('[describeImage] Error:', (error === null || error === void 0 ? void 0 : error.message) || error);
|
|
55
|
+
(0, index_2.err)(null, null, error, null);
|
|
56
|
+
return '[Error al describir imagen]';
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
exports.describeImage = describeImage;
|