@jjlmoya/utils-social 1.1.0 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-social",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -115,31 +115,35 @@ export const content: ToolLocaleContent<SocialImageResizerUI> = {
115
115
  {
116
116
  type: 'grid',
117
117
  columns: [
118
- { title: 'Instagram — Post Cuadrado', body: '1080 × 1080 px · Ratio 1:1' },
119
- { title: 'Instagram — Post Vertical', body: '1080 × 1350 px · Ratio 4:5' },
120
- { title: 'TikTok / Reels — Vertical', body: '1080 × 1920 px · Ratio 9:16' },
121
- { title: 'YouTube — Miniatura', body: '1280 × 720 px · Ratio 16:9' },
122
- { title: 'YouTube — Banner de Canal', body: '2560 × 1440 px · Ratio 16:9' },
123
- { title: 'X (Twitter) — Post Estándar', body: '1200 × 675 px · Ratio 16:9' },
124
- { title: 'LinkedIn — Portada de Perfil', body: '1584 × 396 px · Ratio 4:1' },
118
+ { type: 'card', title: 'Instagram — Post Cuadrado', body: '1080 × 1080 px · Ratio 1:1' },
119
+ { type: 'card', title: 'Instagram — Post Vertical', body: '1080 × 1350 px · Ratio 4:5' },
120
+ { type: 'card', title: 'TikTok / Reels — Vertical', body: '1080 × 1920 px · Ratio 9:16' },
121
+ { type: 'card', title: 'YouTube — Miniatura', body: '1280 × 720 px · Ratio 16:9' },
122
+ { type: 'card', title: 'YouTube — Banner de Canal', body: '2560 × 1440 px · Ratio 16:9' },
123
+ { type: 'card', title: 'X (Twitter) — Post Estándar', body: '1200 × 675 px · Ratio 16:9' },
124
+ { type: 'card', title: 'LinkedIn — Portada de Perfil', body: '1584 × 396 px · Ratio 4:1' },
125
125
  ],
126
126
  },
127
127
  {
128
128
  type: 'grid',
129
129
  columns: [
130
130
  {
131
+ type: 'card',
131
132
  title: 'Instagram',
132
133
  body: 'El formato 1:1 sigue siendo un clásico, pero el 4:5 (Vertical) ocupa más espacio en pantalla y genera hasta un 30% más de interacción en el feed de noticias.',
133
134
  },
134
135
  {
136
+ type: 'card',
135
137
  title: 'Short Form Content',
136
138
  body: 'Para TikTok y Reels, el estándar es 9:16. Este formato inmersivo requiere que el centro de atención esté perfectamente alineado para evitar ser tapado por la UI.',
137
139
  },
138
140
  {
141
+ type: 'card',
139
142
  title: 'Visual Engagement',
140
143
  body: 'Desde miniaturas hasta banners, cada asset tiene un peso específico en el clic. El recorte preciso garantiza que los textos y elementos clave sean siempre legibles.',
141
144
  },
142
145
  {
146
+ type: 'card',
143
147
  title: 'Branding Profesional',
144
148
  body: 'En redes profesionales como LinkedIn, la nitidez es autoridad. Los encabezados requieren ratios específicos para no aparecer pixelados en pantallas de alta densidad.',
145
149
  },
@@ -11,7 +11,35 @@ const { ui } = Astro.props;
11
11
  const t = (ui ?? {}) as TinderPhotoOptimizerUI;
12
12
  ---
13
13
 
14
- <div class="tfo-root">
14
+ <div
15
+ class="tfo-root"
16
+ data-status-optimal={t.statusOptimal}
17
+ data-status-incorrect={t.statusIncorrect}
18
+ data-status-high={t.statusHigh}
19
+ data-status-medium={t.statusMedium}
20
+ data-status-low={t.statusLow}
21
+ data-status-positive={t.statusPositive}
22
+ data-status-neutral={t.statusNeutral}
23
+ data-alert-excellent={t.alertExcellent}
24
+ data-alert-empty-spaces={t.alertEmptySpaces}
25
+ data-alert-almost-invisible={t.alertAlmostInvisible}
26
+ data-alert-too-close={t.alertTooClose}
27
+ data-alert-perfect-size={t.alertPerfectSize}
28
+ data-alert-impactful-look={t.alertImpactfulLook}
29
+ data-alert-eye-line-move-up={t.alertEyeLineMoveUp}
30
+ data-alert-eye-line-move-down={t.alertEyeLineMoveDown}
31
+ data-alert-tilted-head={t.alertTiltedHead}
32
+ data-alert-smile-detected={t.alertSmileDetected}
33
+ data-alert-severe-expression={t.alertSevereExpression}
34
+ data-alert-obstructed-top={t.alertObstructedTop}
35
+ data-alert-obstructed-bottom={t.alertObstructedBottom}
36
+ data-alert-low-light={t.alertLowLight}
37
+ data-alert-cut-off={t.alertCutOff}
38
+ data-alert-pro-tip={t.alertProTip}
39
+ data-canvas-face-detected={t.canvasFaceDetected}
40
+ data-loader-models={t.loaderModels}
41
+ data-loader-analyzing={t.loaderAnalyzing}
42
+ >
15
43
  <div class="tfo-container" id="tfo-optimizer">
16
44
  <input type="file" id="image-input" accept=".jpg,.jpeg,.png,.webp,.gif" style="display: none;" />
17
45
 
@@ -20,7 +48,7 @@ const t = (ui ?? {}) as TinderPhotoOptimizerUI;
20
48
  <div id="ai-loader" class="tfo-ai-loader tfo-hidden">
21
49
  <div class="tfo-loader-content">
22
50
  <div class="tfo-scanner-bar"></div>
23
- <p id="ai-loader-text">{t.loaderAnalyzing}</p>
51
+ <p id="ai-loader-text">{t.loaderModels}</p>
24
52
  </div>
25
53
  </div>
26
54
 
@@ -436,9 +464,9 @@ const t = (ui ?? {}) as TinderPhotoOptimizerUI;
436
464
  const targetImg = img || this.engine.image;
437
465
  if (!targetImg) return;
438
466
  this.aiLoader.classList.remove('tfo-hidden');
439
- this.aiLoaderText.textContent = 'Cargando modelos de visión...';
467
+ this.aiLoaderText.textContent = this.getTranslation('loaderModels') || 'Loading models...';
440
468
  await this.engine.loadModels();
441
- this.aiLoaderText.textContent = 'Analizando composición...';
469
+ this.aiLoaderText.textContent = this.getTranslation('loaderAnalyzing') || 'Analyzing...';
442
470
  const face = await this.engine.detect(targetImg);
443
471
  if (face) {
444
472
  this.autofocusBtn.style.display = 'flex';
@@ -451,21 +479,26 @@ const t = (ui ?? {}) as TinderPhotoOptimizerUI;
451
479
 
452
480
  private onStart(e: MouseEvent | TouchEvent) {
453
481
  if (e instanceof TouchEvent) e.preventDefault();
454
- const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX;
455
- const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY;
482
+ const { x: cx, y: cy } = this.getClientPos(e);
456
483
  const rect = this.canvas.getBoundingClientRect();
457
- const x = (clientX - rect.left) * (this.canvas.width / rect.width);
458
- const y = (clientY - rect.top) * (this.canvas.height / rect.height);
484
+ const x = (cx - rect.left) * (this.canvas.width / rect.width);
485
+ const y = (cy - rect.top) * (this.canvas.height / rect.height);
486
+ this.initInteraction(x, y, cx, cy);
487
+ }
488
+
489
+ private initInteraction(x: number, y: number, cx: number, cy: number) {
459
490
  if (this.isSelecting) {
460
491
  this.selectionRect = { x, y, w: 0, h: 0 };
461
492
  } else {
462
493
  this.isDragging = true;
463
- this.lastPos = { x: clientX, y: clientY };
494
+ this.lastPos = { x: cx, y: cy };
464
495
  }
465
496
  }
466
497
 
467
498
  private getClientPos(e: MouseEvent | TouchEvent) {
468
- const src = e instanceof MouseEvent ? e : e.touches[0];
499
+ const touch = e instanceof TouchEvent ? e.touches[0] : null;
500
+ const src = e instanceof MouseEvent ? e : touch;
501
+ if (!src) return { x: 0, y: 0 };
469
502
  return { x: src.clientX, y: src.clientY };
470
503
  }
471
504
 
@@ -535,31 +568,46 @@ const t = (ui ?? {}) as TinderPhotoOptimizerUI;
535
568
  }
536
569
  }
537
570
 
571
+ private getTranslation(key: string): string {
572
+ const root = this.canvas.closest('.tfo-root') as HTMLElement | null;
573
+ const dataset = root?.dataset as Record<string, string | undefined>;
574
+ return dataset?.[key] || key;
575
+ }
576
+
538
577
  private updateMetrics() {
539
578
  const { metrics } = this.engine.getAnalysis();
540
- const mFace = document.getElementById('metric-face-size');
541
- const mEye = document.getElementById('metric-eye-line');
542
- const mCoverage = document.getElementById('metric-coverage');
543
- const mMood = document.getElementById('metric-mood');
544
- const mQuality = document.getElementById('metric-quality');
545
- if (mFace) mFace.textContent = metrics.faceSize;
546
- if (mEye) mEye.textContent = metrics.eyeLine;
547
- if (mCoverage) mCoverage.textContent = metrics.coverage;
548
- if (mMood) mMood.textContent = metrics.mood;
549
- if (mQuality) mQuality.textContent = metrics.quality;
579
+ const fields = [
580
+ { k: 'faceSize', id: 'metric-face-size' },
581
+ { k: 'eyeLine', id: 'metric-eye-line' },
582
+ { k: 'coverage', id: 'metric-coverage' },
583
+ { k: 'mood', id: 'metric-mood' },
584
+ { k: 'quality', id: 'metric-quality' }
585
+ ];
586
+ fields.forEach(f => {
587
+ const el = document.getElementById(f.id);
588
+ if (!el) return;
589
+ const v = metrics[f.k] as string;
590
+ if (f.k === 'faceSize' || (f.k === 'coverage' && v !== 'statusIncorrect')) el.textContent = v;
591
+ else el.textContent = this.getTranslation(v || '');
592
+ });
550
593
  }
551
594
 
552
- private renderAlerts(alerts: { type: string; text: string }[], stacks: Record<string, HTMLElement | null>) {
595
+ private renderAlerts(alerts: { type: string; textKey: string }[], stacks: Record<string, HTMLElement | null>) {
553
596
  Object.values(stacks).forEach((s) => { if (s) s.innerHTML = ''; });
554
597
  if (alerts.length === 0) {
555
- if (stacks.success) stacks.success.innerHTML = '<div class="tfo-alert tfo-alert--success">Composición técnica excelente.</div>';
598
+ if (stacks.success) {
599
+ const div = document.createElement('div');
600
+ div.className = 'tfo-alert tfo-alert--success';
601
+ div.textContent = this.getTranslation('alertExcellent');
602
+ stacks.success.appendChild(div);
603
+ }
556
604
  } else {
557
605
  alerts.forEach((a) => {
558
606
  const stack = stacks[a.type];
559
607
  if (stack) {
560
608
  const div = document.createElement('div');
561
609
  div.className = `tfo-alert tfo-alert--${a.type}`;
562
- div.textContent = a.text;
610
+ div.textContent = this.getTranslation(a.textKey as string);
563
611
  stack.appendChild(div);
564
612
  }
565
613
  });
@@ -579,12 +627,12 @@ const t = (ui ?? {}) as TinderPhotoOptimizerUI;
579
627
  warning: document.getElementById('stack-warning'),
580
628
  error: document.getElementById('stack-error'),
581
629
  };
582
- this.renderAlerts(alerts, stacks);
630
+ this.renderAlerts(alerts as { type: string; textKey: string }[], stacks);
583
631
  }
584
632
 
585
633
  draw(forceShowFaceBox?: boolean) {
586
634
  const showFaceBox = forceShowFaceBox !== undefined ? forceShowFaceBox : this.toggleFaceBoxSwitch.checked;
587
- this.engine.draw(showFaceBox);
635
+ this.engine.draw(showFaceBox, this.getTranslation('canvasFaceDetected'));
588
636
  if (this.isSelecting && this.selectionRect.w !== 0) {
589
637
  const ctx = this.canvas.getContext('2d')!;
590
638
  ctx.strokeStyle = '#FD297B';
@@ -1,4 +1,4 @@
1
- import type { WithContext, FAQPage, HowToThing, SoftwareApplication } from 'schema-dts';
1
+ import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
2
  import type { ToolLocaleContent } from '../../../types';
3
3
  import type { TinderPhotoOptimizerUI } from '../ui';
4
4
 
@@ -59,7 +59,7 @@ const faqSchema: WithContext<FAQPage> = {
59
59
  })),
60
60
  };
61
61
 
62
- const howToSchema: WithContext<HowToThing> = {
62
+ const howToSchema: WithContext<HowTo> = {
63
63
  '@context': 'https://schema.org',
64
64
  '@type': 'HowTo',
65
65
  name: title,
@@ -244,5 +244,32 @@ export const content: ToolLocaleContent<TinderPhotoOptimizerUI> = {
244
244
  simulatorJob: 'Your Profession',
245
245
  simulatorCity: 'Lives in New York',
246
246
  simulatorDistance: '5 km away',
247
+
248
+ statusOptimal: 'OPTIMAL',
249
+ statusIncorrect: 'INCORRECT',
250
+ statusHigh: 'HIGH',
251
+ statusMedium: 'MEDIUM',
252
+ statusLow: 'LOW',
253
+ statusPositive: 'POSITIVE',
254
+ statusNeutral: 'NEUTRAL',
255
+
256
+ alertExcellent: 'Excellent technical composition.',
257
+ alertEmptySpaces: 'EMPTY SPACES: The image does not cover the entire frame. Adjust zoom or position.',
258
+ alertAlmostInvisible: 'ALMOST INVISIBLE: You are too far away. Zoom in until your face occupies at least a third of the screen.',
259
+ alertTooClose: 'TOO CLOSE: Extreme close-ups can appear aggressive.',
260
+ alertPerfectSize: 'PERFECT SIZE: Your face has the ideal ratio to convey confidence.',
261
+ alertImpactfulLook: 'IMPACTFUL LOOK: Your eyes are on the maximum attention line.',
262
+ alertEyeLineMoveUp: 'EYE LINE: For a professional frame, move your face up slightly to the top line.',
263
+ alertEyeLineMoveDown: 'EYE LINE: For a professional frame, move your face down slightly to the top line.',
264
+ alertTiltedHead: 'TILTED HEAD: A posture that is too tilted can convey instability.',
265
+ alertSmileDetected: 'SMILE DETECTED: Smiles significantly increase the match rate.',
266
+ alertSevereExpression: 'SEVERE EXPRESSION: Try using a photo with a friendlier gesture.',
267
+ alertObstructedTop: 'OBSTRUCTED: The progress bar covers your face.',
268
+ alertObstructedBottom: 'OBSTRUCTED: The information block covers your face.',
269
+ alertLowLight: 'POOR LIGHTING: The AI has difficulty seeing you. Look for a photo with better light.',
270
+ alertCutOff: 'CUT OFF: The face goes outside the vertical frame.',
271
+ alertProTip: 'PRO TIP: If it is a selfie, use Zoom (1.2x) to avoid lens distortions.',
272
+
273
+ canvasFaceDetected: 'Face detected',
247
274
  },
248
275
  };
@@ -1,4 +1,4 @@
1
- import type { WithContext, FAQPage, HowToThing, SoftwareApplication } from 'schema-dts';
1
+ import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
2
  import type { ToolLocaleContent } from '../../../types';
3
3
  import type { TinderPhotoOptimizerUI } from '../ui';
4
4
 
@@ -59,7 +59,7 @@ const faqSchema: WithContext<FAQPage> = {
59
59
  })),
60
60
  };
61
61
 
62
- const howToSchema: WithContext<HowToThing> = {
62
+ const howToSchema: WithContext<HowTo> = {
63
63
  '@context': 'https://schema.org',
64
64
  '@type': 'HowTo',
65
65
  name: title,
@@ -244,5 +244,32 @@ export const content: ToolLocaleContent<TinderPhotoOptimizerUI> = {
244
244
  simulatorJob: 'Tu Profesión',
245
245
  simulatorCity: 'Vive en España',
246
246
  simulatorDistance: 'A 5 km de ti',
247
+
248
+ statusOptimal: 'ÓPTIMA',
249
+ statusIncorrect: 'INCORRECTA',
250
+ statusHigh: 'ALTA',
251
+ statusMedium: 'MEDIA',
252
+ statusLow: 'BAJA',
253
+ statusPositive: 'POSITIVO',
254
+ statusNeutral: 'NEUTRAL',
255
+
256
+ alertExcellent: 'Composición técnica excelente.',
257
+ alertEmptySpaces: 'ESPACIOS VACÍOS: La imagen no cubre todo el encuadre. Ajusta el zoom o la posición.',
258
+ alertAlmostInvisible: 'CASI INVISIBLE: Estás muy lejos. Haz zoom hasta que tu cara ocupe al menos un tercio de la pantalla.',
259
+ alertTooClose: 'DEMASIADO CERCA: Los primeros planos extremos pueden resultar agresivos.',
260
+ alertPerfectSize: 'TAMAÑO PERFECTO: Tu rostro tiene el ratio ideal para transmitir confianza.',
261
+ alertImpactfulLook: 'MIRADA IMPACTANTE: Tus ojos están en la línea de atención máxima.',
262
+ alertEyeLineMoveUp: 'LÍNEA DE OJOS: Para un encuadre profesional, sube un poco el rostro hasta la línea superior.',
263
+ alertEyeLineMoveDown: 'LÍNEA DE OJOS: Para un encuadre profesional, baja un poco el rostro hasta la línea superior.',
264
+ alertTiltedHead: 'CABEZA INCLINADA: Una postura demasiado torcida puede transmitir inestabilidad.',
265
+ alertSmileDetected: 'SONRISA DETECTADA: Las sonrisas aumentan la tasa de match significativamente.',
266
+ alertSevereExpression: 'EXPRESIÓN SEVERA: Intenta usar una foto con un gesto más amable.',
267
+ alertObstructedTop: 'OBSTRUIDO: La barra de progreso tapa tu rostro.',
268
+ alertObstructedBottom: 'OBSTRUIDO: El bloque de información tapa tu cara.',
269
+ alertLowLight: 'ILUMINACIÓN DEFICIENTE: La IA tiene dificultades para verte. Busca una foto con mejor luz.',
270
+ alertCutOff: 'CORTADO: El rostro se sale del encuadre vertical.',
271
+ alertProTip: 'CONSEJO PRO: Si es una selfie, usa el Zoom (1.2x) para evitar distorsiones de lente.',
272
+
273
+ canvasFaceDetected: 'Cara detectada',
247
274
  },
248
275
  };
@@ -1,4 +1,4 @@
1
- import type { WithContext, FAQPage, HowToThing, SoftwareApplication } from 'schema-dts';
1
+ import type { WithContext, FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
2
2
  import type { ToolLocaleContent } from '../../../types';
3
3
  import type { TinderPhotoOptimizerUI } from '../ui';
4
4
 
@@ -59,7 +59,7 @@ const faqSchema: WithContext<FAQPage> = {
59
59
  })),
60
60
  };
61
61
 
62
- const howToSchema: WithContext<HowToThing> = {
62
+ const howToSchema: WithContext<HowTo> = {
63
63
  '@context': 'https://schema.org',
64
64
  '@type': 'HowTo',
65
65
  name: title,
@@ -244,5 +244,32 @@ export const content: ToolLocaleContent<TinderPhotoOptimizerUI> = {
244
244
  simulatorJob: 'Votre Profession',
245
245
  simulatorCity: 'Vit à Paris',
246
246
  simulatorDistance: 'À 5 km de vous',
247
+
248
+ statusOptimal: 'OPTIMALE',
249
+ statusIncorrect: 'INCORRECTE',
250
+ statusHigh: 'HAUTE',
251
+ statusMedium: 'MOYENNE',
252
+ statusLow: 'BASSE',
253
+ statusPositive: 'POSITIF',
254
+ statusNeutral: 'NEUTRE',
255
+
256
+ alertExcellent: 'Excellente composition technique.',
257
+ alertEmptySpaces: 'ESPACES VIDES : L\'image ne couvre pas tout le cadre. Ajustez le zoom ou la position.',
258
+ alertAlmostInvisible: 'PRESQUE INVISIBLE : Vous êtes trop loin. Zoomez jusqu\'à ce que votre visage occupe au moins un tiers de l\'écran.',
259
+ alertTooClose: 'TROP PRÈS : Les gros plans extrêmes peuvent paraître agressifs.',
260
+ alertPerfectSize: 'TAILLE PARFAITE : Votre visage a le ratio idéal pour transmettre la confiance.',
261
+ alertImpactfulLook: 'REGARD IMPACTANT : Vos yeux sont sur la ligne d\'attention maximale.',
262
+ alertEyeLineMoveUp: 'LIGNE DES YEUX : Pour un cadrage professionnel, remontez légèrement le visage jusqu\'à la ligne supérieure.',
263
+ alertEyeLineMoveDown: 'LIGNE DES YEUX : Pour un cadrage professionnel, baissez légèrement le visage jusqu\'à la ligne supérieure.',
264
+ alertTiltedHead: 'TÊTE INCLINÉE : Une posture trop inclinée peut transmettre de l\'instabilité.',
265
+ alertSmileDetected: 'SOURIRE DÉTECTÉ : Les sourires augmentent considérablement le taux de match.',
266
+ alertSevereExpression: 'EXPRESSION SÉVÈRE : Essayez d\'utiliser une photo avec un geste plus amical.',
267
+ alertObstructedTop: 'OBSTRUÉ : La barre de progression cache votre visage.',
268
+ alertObstructedBottom: 'OBSTRUÉ : Le bloc d\'information cache votre visage.',
269
+ alertLowLight: 'ÉCLAIRAGE DÉFICIENT : L\'IA a des difficultés à vous voir. Cherchez une photo avec une meilleure lumière.',
270
+ alertCutOff: 'COUPÉ : Le visage sort du cadre vertical.',
271
+ alertProTip: 'CONSEIL PRO : S\'il s\'agit d\'un selfie, utilisez le Zoom (1.2x) pour éviter les distorsions de l\'objectif.',
272
+
273
+ canvasFaceDetected: 'Visage détecté',
247
274
  },
248
275
  };
@@ -1,3 +1,5 @@
1
+ import type { TinderPhotoOptimizerUI } from './ui';
2
+
1
3
  interface FaceLandmark { x: number; y: number; }
2
4
  interface FaceLandmarks { getLeftEye(): FaceLandmark[]; getRightEye(): FaceLandmark[]; positions: FaceLandmark[]; }
3
5
  interface FaceExpressions { happy: number; angry: number; }
@@ -11,8 +13,8 @@ interface FaceApiGlobal {
11
13
  declare const faceapi: FaceApiGlobal;
12
14
 
13
15
  export interface FaceDetectionResult { detection: { box: FaceBox; score: number }; landmarks: FaceLandmarks | null; expressions: FaceExpressions | null; }
14
- export interface AnalysisAlert { type: 'success' | 'warning' | 'error'; text: string; }
15
- type AnalysisCtx = { alerts: AnalysisAlert[]; metrics: Record<string, string> };
16
+ export interface AnalysisAlert { type: 'success' | 'warning' | 'error'; textKey: keyof TinderPhotoOptimizerUI; }
17
+ type AnalysisCtx = { alerts: AnalysisAlert[]; metrics: Record<string, keyof TinderPhotoOptimizerUI | string> };
16
18
  type ImgGeo = { x: number; y: number; w: number; h: number; canvasW: number; canvasH: number; r: number };
17
19
  type FaceGeo = { fx: number; fy: number; fw: number; fh: number; faceHeightRatio: number };
18
20
 
@@ -55,8 +57,7 @@ export class TinderOptimizerEngine {
55
57
  setManualFace(rect: { x: number; y: number; w: number; h: number }) {
56
58
  if (!this.image) return;
57
59
  const r = Math.max(this.canvas.width / this.image.width, this.canvas.height / this.image.height);
58
- const w = this.image.width * r * this.zoom;
59
- const h = this.image.height * r * this.zoom;
60
+ const w = this.image.width * r * this.zoom, h = this.image.height * r * this.zoom;
60
61
  const imgX = (this.canvas.width - w) / 2 + this.offset.x;
61
62
  const imgY = (this.canvas.height - h) / 2 + this.offset.y;
62
63
  this.faceDetection = {
@@ -66,7 +67,9 @@ export class TinderOptimizerEngine {
66
67
  }
67
68
 
68
69
  updateState(zoom: number, offset: { x: number; y: number }, brightness: number) {
69
- this.zoom = zoom; this.offset = offset; this.brightness = brightness;
70
+ this.zoom = zoom;
71
+ this.offset = offset;
72
+ this.brightness = brightness;
70
73
  }
71
74
 
72
75
  private computeImgGeo(): ImgGeo {
@@ -80,8 +83,8 @@ export class TinderOptimizerEngine {
80
83
  private checkCoverage(ctx: AnalysisCtx, geo: ImgGeo) {
81
84
  const { x, y, w, h, canvasW, canvasH } = geo;
82
85
  const ok = !(x > 0 || y > 0 || x + w < canvasW || y + h < canvasH);
83
- ctx.metrics.coverage = ok ? '100%' : 'INCORRECTA';
84
- if (!ok) ctx.alerts.push({ type: 'error', text: 'ESPACIOS VACÍOS: La imagen no cubre todo el encuadre. Ajusta el zoom o la posición.' });
86
+ ctx.metrics.coverage = ok ? '100%' : 'statusIncorrect';
87
+ if (!ok) ctx.alerts.push({ type: 'error', textKey: 'alertEmptySpaces' });
85
88
  }
86
89
 
87
90
  private getFaceGeo(box: FaceBox, geo: ImgGeo): FaceGeo {
@@ -93,51 +96,57 @@ export class TinderOptimizerEngine {
93
96
 
94
97
  private analyzeFaceSize(ctx: AnalysisCtx, ratio: number) {
95
98
  ctx.metrics.faceSize = `${(ratio * 100).toFixed(1)}%`;
96
- if (ratio < 0.2) ctx.alerts.push({ type: 'error', text: 'CASI INVISIBLE: Estás muy lejos. Haz zoom hasta que tu cara ocupe al menos un tercio de la pantalla.' });
97
- else if (ratio > 0.6) ctx.alerts.push({ type: 'warning', text: 'DEMASIADO CERCA: Los primeros planos extremos pueden resultar agresivos.' });
98
- else if (ratio >= 0.3 && ratio <= 0.5) ctx.alerts.push({ type: 'success', text: 'TAMAÑO PERFECTO: Tu rostro tiene el ratio ideal para transmitir confianza.' });
99
+ if (ratio < 0.2) ctx.alerts.push({ type: 'error', textKey: 'alertAlmostInvisible' });
100
+ else if (ratio > 0.6) ctx.alerts.push({ type: 'warning', textKey: 'alertTooClose' });
101
+ else if (ratio >= 0.3 && ratio <= 0.5) ctx.alerts.push({ type: 'success', textKey: 'alertPerfectSize' });
99
102
  }
100
103
 
101
104
  private analyzeEyeLine(ctx: AnalysisCtx, landmarks: FaceLandmarks, geo: ImgGeo) {
102
- const avgY = (landmarks.getLeftEye()[0].y + landmarks.getRightEye()[0].y) / 2;
105
+ const leftEye = landmarks.getLeftEye(), rightEye = landmarks.getRightEye();
106
+ if (!leftEye || !leftEye.length || !rightEye || !rightEye.length) return;
107
+ const avgY = (leftEye[0].y + rightEye[0].y) / 2;
103
108
  const eyeDiff = (geo.y + avgY * geo.r * this.zoom) - 640;
109
+ this.setEyeMetric(ctx, eyeDiff);
110
+ if (Math.abs(this.estimateRoll(landmarks)) > 15) ctx.alerts.push({ type: 'warning', textKey: 'alertTiltedHead' });
111
+ }
112
+
113
+ private setEyeMetric(ctx: AnalysisCtx, eyeDiff: number) {
104
114
  if (Math.abs(eyeDiff) < 150) {
105
- ctx.metrics.eyeLine = 'ÓPTIMA';
106
- ctx.alerts.push({ type: 'success', text: 'MIRADA IMPACTANTE: Tus ojos están en la línea de atención máxima.' });
115
+ ctx.metrics.eyeLine = 'statusOptimal';
116
+ ctx.alerts.push({ type: 'success', textKey: 'alertImpactfulLook' });
107
117
  } else {
108
- ctx.metrics.eyeLine = eyeDiff > 0 ? 'BAJA' : 'ALTA';
109
- ctx.alerts.push({ type: 'warning', text: `LÍNEA DE OJOS: Para un encuadre profesional, ${eyeDiff > 0 ? 'sube' : 'baja'} un poco el rostro hasta la línea superior.` });
118
+ ctx.metrics.eyeLine = eyeDiff > 0 ? 'statusLow' : 'statusHigh';
119
+ ctx.alerts.push({ type: 'warning', textKey: eyeDiff > 0 ? 'alertEyeLineMoveUp' : 'alertEyeLineMoveDown' });
110
120
  }
111
- if (Math.abs(this.estimateRoll(landmarks)) > 15) ctx.alerts.push({ type: 'warning', text: 'CABEZA INCLINADA: Una postura demasiado torcida puede transmitir inestabilidad.' });
112
121
  }
113
122
 
114
123
  private analyzeMood(ctx: AnalysisCtx, expr: FaceExpressions) {
115
124
  const happy = expr.happy || 0;
116
- ctx.metrics.mood = happy > 0.7 ? 'POSITIVO' : 'NEUTRAL';
117
- if (happy > 0.7) ctx.alerts.push({ type: 'success', text: 'SONRISA DETECTADA: Las sonrisas aumentan la tasa de match significativamente.' });
118
- else if (expr.angry > 0.4) ctx.alerts.push({ type: 'warning', text: 'EXPRESIÓN SEVERA: Intenta usar una foto con un gesto más amable.' });
125
+ ctx.metrics.mood = happy > 0.7 ? 'statusPositive' : 'statusNeutral';
126
+ if (happy > 0.7) ctx.alerts.push({ type: 'success', textKey: 'alertSmileDetected' });
127
+ else if (expr.angry > 0.4) ctx.alerts.push({ type: 'warning', textKey: 'alertSevereExpression' });
119
128
  }
120
129
 
121
130
  private checkFaceObstruction(ctx: AnalysisCtx, fy: number, fh: number) {
122
- if (fy < 300) ctx.alerts.push({ type: 'error', text: 'OBSTRUIDO: La barra de progreso tapa tu rostro.' });
123
- else if (fy + fh > 1400) ctx.alerts.push({ type: 'error', text: 'OBSTRUIDO: El bloque de información tapa tu cara.' });
131
+ if (fy < 300) ctx.alerts.push({ type: 'error', textKey: 'alertObstructedTop' });
132
+ else if (fy + fh > 1400) ctx.alerts.push({ type: 'error', textKey: 'alertObstructedBottom' });
124
133
  }
125
134
 
126
135
  private checkFaceOut(ctx: AnalysisCtx, fg: FaceGeo, canvasW: number, canvasH: number) {
136
+ if (!this.faceDetection) return;
137
+ const confidence = this.faceDetection.detection.score;
138
+ ctx.metrics.quality = confidence > 0.8 ? 'statusHigh' : 'statusMedium';
139
+ if (confidence < 0.6) ctx.alerts.push({ type: 'warning', textKey: 'alertLowLight' });
127
140
  const { fx, fy, fw, fh } = fg;
128
- const confidence = this.faceDetection!.detection.score;
129
- ctx.metrics.quality = confidence > 0.8 ? 'ALTA' : 'MEDIA';
130
- if (confidence < 0.6) ctx.alerts.push({ type: 'warning', text: 'ILUMINACIÓN DEFICIENTE: La IA tiene dificultades para verte. Busca una foto con mejor luz.' });
131
- if (fx < 0 || fx + fw > canvasW || fy < 0 || fy + fh > canvasH) ctx.alerts.push({ type: 'error', text: 'CORTADO: El rostro se sale del encuadre vertical.' });
141
+ if (fx < 0 || fx + fw > canvasW || fy < 0 || fy + fh > canvasH) ctx.alerts.push({ type: 'error', textKey: 'alertCutOff' });
132
142
  this.checkFaceObstruction(ctx, fy, fh);
133
143
  }
134
144
 
135
145
  getAnalysis() {
136
146
  const alerts: AnalysisAlert[] = [];
137
- const metrics: Record<string, string> = { faceSize: '0%', eyeLine: '--', coverage: '0%', mood: '--', quality: '--' };
147
+ const metrics: Record<string, keyof TinderPhotoOptimizerUI | string> = { faceSize: '0%', eyeLine: '--', coverage: '0%', mood: '--', quality: '--' };
138
148
  if (!this.image) return { alerts, metrics };
139
- const ctx: AnalysisCtx = { alerts, metrics };
140
- const geo = this.computeImgGeo();
149
+ const ctx: AnalysisCtx = { alerts, metrics }, geo = this.computeImgGeo();
141
150
  this.checkCoverage(ctx, geo);
142
151
  if (this.faceDetection) {
143
152
  const fg = this.getFaceGeo(this.faceDetection.detection.box, geo);
@@ -146,54 +155,51 @@ export class TinderOptimizerEngine {
146
155
  if (this.faceDetection.expressions) this.analyzeMood(ctx, this.faceDetection.expressions);
147
156
  this.checkFaceOut(ctx, fg, geo.canvasW, geo.canvasH);
148
157
  }
149
- if (this.zoom < 1.15) alerts.push({ type: 'warning', text: 'CONSEJO PRO: Si es una selfie, usa el Zoom (1.2x) para evitar distorsiones de lente.' });
158
+ if (this.zoom < 1.15) alerts.push({ type: 'warning', textKey: 'alertProTip' });
150
159
  return { alerts, metrics };
151
160
  }
152
161
 
153
162
  private estimateRoll(landmarks: FaceLandmarks): number {
154
- const l = landmarks.getLeftEye()[0], r = landmarks.getRightEye()[0];
163
+ const leftEye = landmarks.getLeftEye(), rightEye = landmarks.getRightEye();
164
+ if (!leftEye || !leftEye.length || !rightEye || !rightEye.length) return 0;
165
+ const l = leftEye[0], r = rightEye[0];
155
166
  return Math.atan2(r.y - l.y, r.x - l.x) * (180 / Math.PI);
156
167
  }
157
168
 
158
169
  getOptimalTransform() {
159
170
  if (!this.image || !this.faceDetection) return null;
160
- const box = this.faceDetection.detection.box;
161
- const r = Math.max(this.canvas.width / this.image.width, this.canvas.height / this.image.height);
171
+ const box = this.faceDetection.detection.box, r = Math.max(this.canvas.width / this.image.width, this.canvas.height / this.image.height);
162
172
  const optimalZoom = Math.min(Math.max((this.canvas.height * 0.25) / (box.height * r), 1), 3);
163
173
  const faceCX = box.x + box.width / 2, faceCY = box.y + box.height / 2;
164
174
  const w = this.image.width * r * optimalZoom, h = this.image.height * r * optimalZoom;
165
175
  return { zoom: optimalZoom, x: 540 - (this.canvas.width - w) / 2 - faceCX * r * optimalZoom, y: 800 - (this.canvas.height - h) / 2 - faceCY * r * optimalZoom };
166
176
  }
167
177
 
168
- private drawFaceBox(x: number, y: number, r: number) {
169
- const box = this.faceDetection!.detection.box;
178
+ private drawFaceBox(x: number, y: number, r: number, label: string) {
179
+ if (!this.faceDetection) return;
180
+ const box = this.faceDetection.detection.box;
170
181
  const fx = x + box.x * r * this.zoom, fy = y + box.y * r * this.zoom;
171
182
  const fw = box.width * r * this.zoom, fh = box.height * r * this.zoom;
172
- this.ctx.strokeStyle = '#00ff00'; this.ctx.lineWidth = 6;
173
- this.ctx.strokeRect(fx, fy, fw, fh);
174
- this.ctx.fillStyle = '#00ff00'; this.ctx.font = 'bold 40px Arial';
175
- this.ctx.fillText('Cara detectada', fx, fy - 10);
176
- const landmarks = this.faceDetection!.landmarks;
183
+ this.ctx.strokeStyle = '#00ff00'; this.ctx.lineWidth = 6; this.ctx.strokeRect(fx, fy, fw, fh);
184
+ this.ctx.fillStyle = '#00ff00'; this.ctx.font = 'bold 40px Arial'; this.ctx.fillText(label, fx, fy - 10);
185
+ const landmarks = this.faceDetection.landmarks;
177
186
  if (landmarks) {
178
187
  this.ctx.fillStyle = 'rgba(0, 255, 0, 0.5)';
179
188
  landmarks.positions.forEach((p: FaceLandmark) => {
180
- this.ctx.beginPath();
181
- this.ctx.arc(x + p.x * r * this.zoom, y + p.y * r * this.zoom, 2, 0, 2 * Math.PI);
182
- this.ctx.fill();
189
+ this.ctx.beginPath(); this.ctx.arc(x + p.x * r * this.zoom, y + p.y * r * this.zoom, 2, 0, 2 * Math.PI); this.ctx.fill();
183
190
  });
184
191
  }
185
192
  }
186
193
 
187
- draw(showFaceBox: boolean) {
194
+ draw(showFaceBox: boolean, label?: string) {
188
195
  if (!this.image) return;
189
196
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
190
197
  this.ctx.filter = `brightness(${this.brightness}%)`;
191
198
  const iw = this.image.width, ih = this.image.height;
192
199
  const r = Math.max(this.canvas.width / iw, this.canvas.height / ih);
193
200
  const w = iw * r * this.zoom, h = ih * r * this.zoom;
194
- const x = (this.canvas.width - w) / 2 + this.offset.x;
195
- const y = (this.canvas.height - h) / 2 + this.offset.y;
201
+ const x = (this.canvas.width - w) / 2 + this.offset.x, y = (this.canvas.height - h) / 2 + this.offset.y;
196
202
  this.ctx.drawImage(this.image, x, y, w, h);
197
- if (showFaceBox && this.faceDetection) this.drawFaceBox(x, y, r);
203
+ if (showFaceBox && this.faceDetection) this.drawFaceBox(x, y, r, label || '');
198
204
  }
199
205
  }
@@ -34,4 +34,31 @@ export interface TinderPhotoOptimizerUI extends Record<string, string> {
34
34
  simulatorJob: string;
35
35
  simulatorCity: string;
36
36
  simulatorDistance: string;
37
+
38
+ statusOptimal: string;
39
+ statusIncorrect: string;
40
+ statusHigh: string;
41
+ statusMedium: string;
42
+ statusLow: string;
43
+ statusPositive: string;
44
+ statusNeutral: string;
45
+
46
+ alertExcellent: string;
47
+ alertEmptySpaces: string;
48
+ alertAlmostInvisible: string;
49
+ alertTooClose: string;
50
+ alertPerfectSize: string;
51
+ alertImpactfulLook: string;
52
+ alertEyeLineMoveUp: string;
53
+ alertEyeLineMoveDown: string;
54
+ alertTiltedHead: string;
55
+ alertSmileDetected: string;
56
+ alertSevereExpression: string;
57
+ alertObstructedTop: string;
58
+ alertObstructedBottom: string;
59
+ alertLowLight: string;
60
+ alertCutOff: string;
61
+ alertProTip: string;
62
+
63
+ canvasFaceDetected: string;
37
64
  }