@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 +1 -1
- package/src/tool/socialImageResizer/i18n/es.ts +11 -7
- package/src/tool/tinderPhotoOptimizer/component.astro +73 -25
- package/src/tool/tinderPhotoOptimizer/i18n/en.ts +29 -2
- package/src/tool/tinderPhotoOptimizer/i18n/es.ts +29 -2
- package/src/tool/tinderPhotoOptimizer/i18n/fr.ts +29 -2
- package/src/tool/tinderPhotoOptimizer/logic.ts +52 -46
- package/src/tool/tinderPhotoOptimizer/ui.ts +27 -0
package/package.json
CHANGED
|
@@ -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
|
|
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.
|
|
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 = '
|
|
467
|
+
this.aiLoaderText.textContent = this.getTranslation('loaderModels') || 'Loading models...';
|
|
440
468
|
await this.engine.loadModels();
|
|
441
|
-
this.aiLoaderText.textContent = '
|
|
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
|
|
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 = (
|
|
458
|
-
const y = (
|
|
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:
|
|
494
|
+
this.lastPos = { x: cx, y: cy };
|
|
464
495
|
}
|
|
465
496
|
}
|
|
466
497
|
|
|
467
498
|
private getClientPos(e: MouseEvent | TouchEvent) {
|
|
468
|
-
const
|
|
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
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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;
|
|
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)
|
|
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.
|
|
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,
|
|
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<
|
|
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,
|
|
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<
|
|
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,
|
|
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<
|
|
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';
|
|
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;
|
|
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%' : '
|
|
84
|
-
if (!ok) ctx.alerts.push({ type: 'error',
|
|
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',
|
|
97
|
-
else if (ratio > 0.6) ctx.alerts.push({ type: 'warning',
|
|
98
|
-
else if (ratio >= 0.3 && ratio <= 0.5) ctx.alerts.push({ type: 'success',
|
|
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
|
|
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 = '
|
|
106
|
-
ctx.alerts.push({ type: 'success',
|
|
115
|
+
ctx.metrics.eyeLine = 'statusOptimal';
|
|
116
|
+
ctx.alerts.push({ type: 'success', textKey: 'alertImpactfulLook' });
|
|
107
117
|
} else {
|
|
108
|
-
ctx.metrics.eyeLine = eyeDiff > 0 ? '
|
|
109
|
-
ctx.alerts.push({ type: 'warning',
|
|
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 ? '
|
|
117
|
-
if (happy > 0.7) ctx.alerts.push({ type: 'success',
|
|
118
|
-
else if (expr.angry > 0.4) ctx.alerts.push({ type: 'warning',
|
|
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',
|
|
123
|
-
else if (fy + fh > 1400) ctx.alerts.push({ type: 'error',
|
|
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
|
-
|
|
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',
|
|
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
|
|
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
|
-
|
|
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.
|
|
174
|
-
|
|
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
|
}
|