dubs-server 1.0.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/.claude/settings.local.json +280 -0
- package/CLAUDE.md +46 -0
- package/CONNECT4_PRODUCTION_DEPLOY.md +155 -0
- package/CURRENT_SESSION.md +171 -0
- package/CURRENT_SESSION_DRAW.md +516 -0
- package/MARCH_MADNESS_SURVIVOR.md +254 -0
- package/PANDA.md +166 -0
- package/Procfile +4 -0
- package/README.md +476 -0
- package/controllers/livescoresController.js +376 -0
- package/controllers/pickemController.js +554 -0
- package/controllers/survivorAdminController.js +887 -0
- package/controllers/survivorController.js +623 -0
- package/cron/oracleMonitor.js +77 -0
- package/cron/pickemOracleMonitor.js +73 -0
- package/data/jackpot-history.json +952 -0
- package/data/ncaaTeams.js +406 -0
- package/documentation/API_SECURITY_GUIDE.md +327 -0
- package/documentation/ARCADE_API.md +593 -0
- package/documentation/ARCADE_IMPLEMENTATION_SUMMARY.md +399 -0
- package/documentation/ARCADE_QUICKSTART.md +242 -0
- package/documentation/AUTOMATIC_MODE_ORACLE.md +321 -0
- package/documentation/BUG_FIX_COHORT_DATE_DISPLAY.md +171 -0
- package/documentation/CLAIM_MIGRATION_INSTRUCTIONS.md +52 -0
- package/documentation/CLAIM_STATUS_FIX.md +67 -0
- package/documentation/CLI_TOOL_GUIDE.md +372 -0
- package/documentation/COHORT_RETENTION_ANALYSIS.md +295 -0
- package/documentation/COHORT_RETENTION_IMPLEMENTATION_COMPLETE.md +461 -0
- package/documentation/COHORT_RETENTION_SUMMARY.md +204 -0
- package/documentation/COMPLETE_PROJECT_SUMMARY.md +490 -0
- package/documentation/DATABASE_QUERIES.md +269 -0
- package/documentation/DATABASE_RETENTION_POLICY.md +390 -0
- package/documentation/DATABASE_SETUP_GUIDE.md +361 -0
- package/documentation/DATABASE_SETUP_SUMMARY.md +247 -0
- package/documentation/DEMO_API_CURL_COMMANDS.md +656 -0
- package/documentation/DEPLOYMENT_SUMMARY.txt +100 -0
- package/documentation/DUPLICATE_NOTIFICATIONS_FIXED.md +201 -0
- package/documentation/EXCHANGE_RATES_INTEGRATION.md +371 -0
- package/documentation/FINAL_API_PROTECTION_TABLE.md +175 -0
- package/documentation/GAME_START_NOTIFICATIONS_DEPLOYMENT.md +256 -0
- package/documentation/GAME_START_NOTIFICATIONS_INTEGRATION.md +275 -0
- package/documentation/HEROKU_DEPLOYMENT.md +134 -0
- package/documentation/HEROKU_SCHEDULER_SETUP.md +271 -0
- package/documentation/JACKPOT_API.md +521 -0
- package/documentation/JACKPOT_DEPLOYMENT_GUIDE.md +362 -0
- package/documentation/JWT_IMPLEMENTATION_SUMMARY.md +373 -0
- package/documentation/JWT_QUICK_SETUP.md +268 -0
- package/documentation/JWT_TESTING_GUIDE.md +404 -0
- package/documentation/KEEPER_RECOVERY_GUIDE.md +381 -0
- package/documentation/KEEPER_SETUP.md +206 -0
- package/documentation/KEEPER_STATE_MACHINE.md +423 -0
- package/documentation/LATEST_PRODUCTION_SETUP.md +387 -0
- package/documentation/LOCAL_VOTING_TEST.md +279 -0
- package/documentation/ORACLE_FIXES_SUMMARY.md +188 -0
- package/documentation/ORACLE_POSTGRESQL_UPDATE.md +202 -0
- package/documentation/PAYMENT_DEPLOYMENT.md +209 -0
- package/documentation/PNL_TRACKING_SETUP.md +189 -0
- package/documentation/PREVENTING_LOCKUP_ERRORS.md +472 -0
- package/documentation/PRODUCTION_READY_SUMMARY.md +227 -0
- package/documentation/PUBLIC_VS_PRIVATE_ENDPOINTS.md +278 -0
- package/documentation/QUICK_AUTH_SETUP.md +99 -0
- package/documentation/QUICK_DEPLOY.md +224 -0
- package/documentation/QUICK_FIX.md +114 -0
- package/documentation/QUICK_START.md +152 -0
- package/documentation/REFEREE_MODE_GUIDE.md +392 -0
- package/documentation/RETENTION_CORE_ACTION_UPDATE.md +313 -0
- package/documentation/RETENTION_UPDATE_SUMMARY.md +108 -0
- package/documentation/RUN_MIGRATION_NOW.md +39 -0
- package/documentation/SCRIPTS_UPDATE_SUMMARY.md +251 -0
- package/documentation/SETUP_GUIDE.md +184 -0
- package/documentation/STATE_MACHINE_IMPLEMENTATION.md +250 -0
- package/documentation/TELEGRAM_NOTIFICATIONS_DIAGNOSIS.md +361 -0
- package/documentation/UNIFIED_ARCHITECTURE.md +231 -0
- package/documentation/VOTING_DEPLOYMENT_SUMMARY.md +392 -0
- package/documentation/WEBSOCKET_ARCHITECTURE.md +881 -0
- package/documentation/WHAT_WE_BUILT_TODAY.md +369 -0
- package/documentation/latest/LATEST_PRODUCTION_SETUP.md +865 -0
- package/ecosystem.config.js +65 -0
- package/env.template +125 -0
- package/middleware/apiKeyAuth.js +136 -0
- package/middleware/authenticate.js +214 -0
- package/middleware/developerUserAuth.js +76 -0
- package/middleware/socketAuth.js +69 -0
- package/package.json +49 -0
- package/postman/Dubs-API-v1-With-Voting.postman_collection.json +555 -0
- package/postman/Dubs-API-v1.postman_collection.json +205 -0
- package/postman/Dubs_Developer_API.postman_collection.json +662 -0
- package/postman/QUICKSTART.md +118 -0
- package/postman/QUICK_REFERENCE.md +246 -0
- package/postman/README.md +71 -0
- package/postman/VOTING_API_GUIDE.md +426 -0
- package/refactor/Animations.md +148 -0
- package/refactor/Chat.md +252 -0
- package/routes/actionsRoutes.js +699 -0
- package/routes/adminRoutes.js +370 -0
- package/routes/analyticsRoutes.js +1262 -0
- package/routes/arcadeRoutes.js +557 -0
- package/routes/authRoutes.js +2310 -0
- package/routes/avatarRoutes.js +85 -0
- package/routes/botRoutes.js +211 -0
- package/routes/chatRoutes.js +377 -0
- package/routes/cryptoPriceRoutes.js +105 -0
- package/routes/developerRoutes.js +4201 -0
- package/routes/deviceRoutes.js +214 -0
- package/routes/dmRoutes.js +167 -0
- package/routes/esportsRoutes.js +806 -0
- package/routes/exchangeRateRoutes.js +233 -0
- package/routes/gamesRoutes.js +3028 -0
- package/routes/jackpotRoutes.js +754 -0
- package/routes/keeperMonitoringRoutes.js +156 -0
- package/routes/keeperWebhookRoutes.js +466 -0
- package/routes/livescoresRoutes.js +31 -0
- package/routes/pickemAdminRoutes.js +199 -0
- package/routes/pickemRoutes.js +231 -0
- package/routes/playerStatsRoutes.js +147 -0
- package/routes/portfolioRoutes.js +217 -0
- package/routes/promoRoutes.js +418 -0
- package/routes/referralEarningsRoutes.js +392 -0
- package/routes/socialRoutes.js +459 -0
- package/routes/sportsRoutes.js +1271 -0
- package/routes/survivorAdminRoutes.js +345 -0
- package/routes/survivorRoutes.js +756 -0
- package/routes/uploadRoutes.js +256 -0
- package/routes/userProfileRoutes.js +244 -0
- package/routes/whatsNewRoutes.js +331 -0
- package/scripts/.claude/settings.local.json +15 -0
- package/scripts/README.md +170 -0
- package/scripts/RESTART_EVERYTHING.sh +104 -0
- package/scripts/add-claim-columns.sql +48 -0
- package/scripts/add-crypto-prices-cache.sql +27 -0
- package/scripts/add-exchange-rates-cache.sql +40 -0
- package/scripts/add-game-invite-column.sql +23 -0
- package/scripts/add-game-invite-notification.sql +33 -0
- package/scripts/add-game-invite-telegram-pref.sql +16 -0
- package/scripts/add-game-joined-notification.sql +16 -0
- package/scripts/add-game-joined-pref.js +40 -0
- package/scripts/add-game-joined-preference.sql +6 -0
- package/scripts/add-game-start-notifications.sql +41 -0
- package/scripts/add-notification-flags-to-games.sql +55 -0
- package/scripts/add-pending-game-dismissals.sql +19 -0
- package/scripts/add-preferred-currency.sql +34 -0
- package/scripts/add-winner-columns.js +61 -0
- package/scripts/add_mention_system.sql +53 -0
- package/scripts/add_payment_system.sql +96 -0
- package/scripts/add_sports_event_id_column.sql +22 -0
- package/scripts/analyze-cohort-data-heroku.js +276 -0
- package/scripts/analyze-cohort-data.js +295 -0
- package/scripts/analyze-prod-cohorts.sh +10 -0
- package/scripts/backfill-matchup-images.js +245 -0
- package/scripts/backfill-missing-signatures.js +175 -0
- package/scripts/backfill-referral-earnings.js +202 -0
- package/scripts/check-chat-schema.js +130 -0
- package/scripts/check-db.sh +14 -0
- package/scripts/check_oracle_in_game.js +54 -0
- package/scripts/cleanup-database.js +193 -0
- package/scripts/clear-notification-cache.js +85 -0
- package/scripts/convert-mnemonic.js +50 -0
- package/scripts/create-users-table.sql +44 -0
- package/scripts/debug-cohort-counts.js +248 -0
- package/scripts/debug-winner-calc.js +84 -0
- package/scripts/deploy-payment-system.sh +118 -0
- package/scripts/deploy-to-heroku.sh +63 -0
- package/scripts/diagnose-locked-round.js +143 -0
- package/scripts/dubs-cli.js +720 -0
- package/scripts/dump-account.js +65 -0
- package/scripts/find-vrf-offset.js +48 -0
- package/scripts/fix-chat-notifications-constraint.sql +122 -0
- package/scripts/fix-claim-columns.js +124 -0
- package/scripts/fix-constraint-now.js +44 -0
- package/scripts/fix-lock-timestamps.js +96 -0
- package/scripts/fix-locked-round.sh +126 -0
- package/scripts/fix-missing-badges.sql +91 -0
- package/scripts/fix-payment-notifications.sql +41 -0
- package/scripts/force-new-round.js +55 -0
- package/scripts/force-resolve-and-claim.js +278 -0
- package/scripts/important/README.md +115 -0
- package/scripts/important/authority-force-lock.js +197 -0
- package/scripts/important/authority-resolve-game.js +267 -0
- package/scripts/important/check-game-status.js +373 -0
- package/scripts/important/list-pending-games-by-version.js +270 -0
- package/scripts/important/reconcile-v1-v2-payouts.js +270 -0
- package/scripts/initialize-jackpot.js +111 -0
- package/scripts/jackpot/.claude/settings.local.json +10 -0
- package/scripts/jackpot/force-reset.js +84 -0
- package/scripts/jackpot/initialize-mainnet.js +100 -0
- package/scripts/jackpot/keeper.js +742 -0
- package/scripts/jackpot/status.js +107 -0
- package/scripts/jackpot/update-round-duration.js +143 -0
- package/scripts/keeper-bot.js +112 -0
- package/scripts/list-pending-games.js +131 -0
- package/scripts/migrate-chat-v2.js +127 -0
- package/scripts/migrate-chat-winners.js +84 -0
- package/scripts/migrate-chat.sh +17 -0
- package/scripts/migrate-game-invite.js +83 -0
- package/scripts/migrate-heroku-game-notifications.sh +159 -0
- package/scripts/migrations/001_analytics_tables.sql +422 -0
- package/scripts/migrations/002_add_matchup_image_url.sql +14 -0
- package/scripts/migrations/003_referral_earnings.sql +208 -0
- package/scripts/migrations/004_add_whats_new_notification_type.sql +62 -0
- package/scripts/migrations/005_add_connect4_your_turn_notification.sql +61 -0
- package/scripts/migrations/005_push_notifications.sql +55 -0
- package/scripts/migrations/006_add_draw_team_players.sql +28 -0
- package/scripts/migrations/006_add_game_cancelled_notification.sql +62 -0
- package/scripts/migrations/007_add_gif_url.sql +8 -0
- package/scripts/migrations/008_add_connect4_columns.sql +139 -0
- package/scripts/migrations/008_add_pool_tracking.sql +22 -0
- package/scripts/migrations/009_create_survivor_pool_tables.sql +174 -0
- package/scripts/migrations/010_add_survivor_pool_outcome.sql +28 -0
- package/scripts/migrations/011_create_developer_tables.sql +67 -0
- package/scripts/migrations/011_fix_keeper_tables.sql +85 -0
- package/scripts/migrations/012_create_developer_webhooks.sql +31 -0
- package/scripts/migrations/013_add_network_mode.sql +18 -0
- package/scripts/migrations/014_create_developer_app_users.sql +19 -0
- package/scripts/migrations/015_add_ui_config.sql +4 -0
- package/scripts/migrations/016_add_resolution_secret.sql +4 -0
- package/scripts/migrations/017_add_external_game_id.sql +3 -0
- package/scripts/migrations/018_create_pickem_tables.sql +115 -0
- package/scripts/migrations/019_expo_push_tokens.sql +19 -0
- package/scripts/migrations/create_whats_new_tables.sql +88 -0
- package/scripts/migrations/drop_live_games_tables.sql +34 -0
- package/scripts/open-jackpot-round.js +85 -0
- package/scripts/purge-all-data.sh +329 -0
- package/scripts/purge-all-data.sql +142 -0
- package/scripts/purge-heroku-data.sh +149 -0
- package/scripts/purge-heroku-data.sql +62 -0
- package/scripts/rebuild-heroku-database.sh +113 -0
- package/scripts/recover-funds.js +357 -0
- package/scripts/regenerate-epl-images.js +278 -0
- package/scripts/resize-s3-matchup-images.js +374 -0
- package/scripts/resolve-direct.js +88 -0
- package/scripts/resolve-mock-game.js +124 -0
- package/scripts/resolve-pickem-game.js +55 -0
- package/scripts/resolve-round-manual.js +83 -0
- package/scripts/resolve-stuck-game.js +382 -0
- package/scripts/resolve-stuck-round.js +42 -0
- package/scripts/run-connect4-migration.sh +16 -0
- package/scripts/run-mention-migration.sh +32 -0
- package/scripts/run-payment-migration.sh +51 -0
- package/scripts/run-preferred-currency-migration.sh +31 -0
- package/scripts/run-referral-earnings-migration.sh +32 -0
- package/scripts/run-survivor-outcome-migration.sh +16 -0
- package/scripts/seed-test-users.js +346 -0
- package/scripts/setup-auth-tables.js +78 -0
- package/scripts/setup-complete-database.sql +992 -0
- package/scripts/setup-database-fresh.sh +359 -0
- package/scripts/setup-heroku-keeper.sh +48 -0
- package/scripts/setup-keeper-database.js +83 -0
- package/scripts/setup-keeper-state-db.sql +110 -0
- package/scripts/setup-oracle.sh +39 -0
- package/scripts/setup-pnl-tracking.js +111 -0
- package/scripts/start-devnet.sh +14 -0
- package/scripts/test-arcade-devnet.sh +160 -0
- package/scripts/test-arcade-match.sh +109 -0
- package/scripts/test-automatic-mode.sh +239 -0
- package/scripts/test-connect4-cancel-claim.js +370 -0
- package/scripts/test-connect4-e2e.js +369 -0
- package/scripts/test-connect4-resolve.js +369 -0
- package/scripts/test-game-state-endpoint.js +136 -0
- package/scripts/test-invite-notification.js +86 -0
- package/scripts/test-jackpot-api.sh +71 -0
- package/scripts/test-poll-confirmation.js +267 -0
- package/scripts/test-resolve-game.js +271 -0
- package/scripts/test-resolve-signature.js +223 -0
- package/scripts/test-signature-preservation.js +124 -0
- package/scripts/test-state-machine.js +291 -0
- package/scripts/test-webhook-receiver.js +60 -0
- package/scripts/update-notification-constraint.js +52 -0
- package/scripts/verify-account-layout.js +145 -0
- package/scripts/verify-winner-algorithm.js +278 -0
- package/server.js +5259 -0
- package/services/arcadeMatchService.js +763 -0
- package/services/automaticGameOracle.js +1596 -0
- package/services/chatService.js +1612 -0
- package/services/connect4GameService.js +1049 -0
- package/services/connect4NotificationService.js +374 -0
- package/services/cryptoPriceService.js +223 -0
- package/services/customGameResolver.js +260 -0
- package/services/db.js +79 -0
- package/services/directMessageService.js +389 -0
- package/services/discordNotifications.js +160 -0
- package/services/exchangeRateService.js +289 -0
- package/services/expoPushService.js +314 -0
- package/services/gamesCacheService.js +539 -0
- package/services/jackpotHistory.js +331 -0
- package/services/jackpotService.js +856 -0
- package/services/keeperStateService.js +355 -0
- package/services/matchupImageService.js +591 -0
- package/services/notificationCacheService.js +407 -0
- package/services/pickemOracle.js +440 -0
- package/services/playerStatsService.js +389 -0
- package/services/portfolioService.js +555 -0
- package/services/promoService.js +757 -0
- package/services/promoTreasuryService.js +239 -0
- package/services/pushNotifications.js +353 -0
- package/services/redisService.js +422 -0
- package/services/referralEarningsService.js +728 -0
- package/services/s3Service.js +396 -0
- package/services/socialService.js +1202 -0
- package/services/survivorOracle.js +469 -0
- package/services/survivorSimulator.js +475 -0
- package/services/telegramNotifications.js +461 -0
- package/services/userProfileStatsService.js +1185 -0
- package/services/whatsNewService.js +388 -0
- package/utils/urlHelper.js +95 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🎨 Matchup Image Generator Service
|
|
3
|
+
*
|
|
4
|
+
* Server-side generation of matchup images using node-canvas.
|
|
5
|
+
* Generates a combined PNG image with:
|
|
6
|
+
* - Left/right team logos with radial gradient backgrounds
|
|
7
|
+
* - League logo centered on the seam
|
|
8
|
+
* - Noise texture overlay
|
|
9
|
+
* - Center highlight band
|
|
10
|
+
*
|
|
11
|
+
* Images are generated ONCE at game creation and uploaded to S3.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { createCanvas, loadImage } = require('canvas');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
|
|
18
|
+
// Configuration - Full quality size (will be resized to 300x158 on upload for optimization)
|
|
19
|
+
const DEFAULT_WIDTH = 600;
|
|
20
|
+
const DEFAULT_HEIGHT = 315;
|
|
21
|
+
const TEAM_LOGO_MAX_SIZE = 200;
|
|
22
|
+
const LEAGUE_LOGO_MAX_SIZE = 60;
|
|
23
|
+
|
|
24
|
+
// Palette extraction settings
|
|
25
|
+
const ALPHA_THRESHOLD = 24;
|
|
26
|
+
const SATURATION_THRESHOLD = 18;
|
|
27
|
+
const MIN_CHANNEL_MAX = 30;
|
|
28
|
+
const SAMPLE_MAX_PIXELS = 45000;
|
|
29
|
+
const EXTRACTION_MAX_DIMENSION = 320;
|
|
30
|
+
|
|
31
|
+
// Gradient settings
|
|
32
|
+
const INNER_STOP = 0.45;
|
|
33
|
+
const NOISE_STRENGTH = 0.06;
|
|
34
|
+
const HIGHLIGHT_WIDTH = 80;
|
|
35
|
+
const HIGHLIGHT_ALPHA = 0.35;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Simple hash function for deterministic noise
|
|
39
|
+
*/
|
|
40
|
+
function hash(x, y) {
|
|
41
|
+
let h = x * 374761393 + y * 668265263;
|
|
42
|
+
h = (h ^ (h >> 15)) * 2246822507;
|
|
43
|
+
h = (h ^ (h >> 13)) * 3266489917;
|
|
44
|
+
return (h ^ (h >> 16)) >>> 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Convert RGB to hex string
|
|
49
|
+
*/
|
|
50
|
+
function rgbToHex(r, g, b) {
|
|
51
|
+
const toHex = (n) => {
|
|
52
|
+
const hex = Math.round(Math.max(0, Math.min(255, n))).toString(16);
|
|
53
|
+
return hex.length === 1 ? '0' + hex : hex;
|
|
54
|
+
};
|
|
55
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract color palette from a logo image
|
|
60
|
+
*/
|
|
61
|
+
function extractLogoPalette(img, canvas, ctx) {
|
|
62
|
+
// Scale down for faster processing
|
|
63
|
+
const scale = Math.min(
|
|
64
|
+
EXTRACTION_MAX_DIMENSION / img.width,
|
|
65
|
+
EXTRACTION_MAX_DIMENSION / img.height,
|
|
66
|
+
1
|
|
67
|
+
);
|
|
68
|
+
const extractWidth = Math.floor(img.width * scale);
|
|
69
|
+
const extractHeight = Math.floor(img.height * scale);
|
|
70
|
+
|
|
71
|
+
// Create temporary canvas for extraction
|
|
72
|
+
const extractCanvas = createCanvas(extractWidth, extractHeight);
|
|
73
|
+
const extractCtx = extractCanvas.getContext('2d');
|
|
74
|
+
|
|
75
|
+
// Draw logo
|
|
76
|
+
extractCtx.drawImage(img, 0, 0, extractWidth, extractHeight);
|
|
77
|
+
|
|
78
|
+
// Get image data
|
|
79
|
+
const imageData = extractCtx.getImageData(0, 0, extractWidth, extractHeight);
|
|
80
|
+
const data = imageData.data;
|
|
81
|
+
const pixels = [];
|
|
82
|
+
|
|
83
|
+
// Collect valid logo pixels
|
|
84
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
85
|
+
const r = data[i];
|
|
86
|
+
const g = data[i + 1];
|
|
87
|
+
const b = data[i + 2];
|
|
88
|
+
const a = data[i + 3];
|
|
89
|
+
|
|
90
|
+
// Skip transparent pixels
|
|
91
|
+
if (a < ALPHA_THRESHOLD) continue;
|
|
92
|
+
|
|
93
|
+
// Calculate luminance and saturation
|
|
94
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
95
|
+
const max = Math.max(r, g, b);
|
|
96
|
+
const min = Math.min(r, g, b);
|
|
97
|
+
const saturation = max - min;
|
|
98
|
+
|
|
99
|
+
// Filter to logo pixels (saturated, visible colors)
|
|
100
|
+
if (saturation >= SATURATION_THRESHOLD && max >= MIN_CHANNEL_MAX) {
|
|
101
|
+
pixels.push({ r, g, b, luminance, saturation });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// If too few pixels, relax saturation threshold
|
|
106
|
+
let validPixels = pixels;
|
|
107
|
+
if (validPixels.length < 100) {
|
|
108
|
+
validPixels = pixels.filter(p => p.saturation >= Math.max(5, SATURATION_THRESHOLD - 5));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (validPixels.length === 0) {
|
|
112
|
+
// Fallback to a neutral palette
|
|
113
|
+
return {
|
|
114
|
+
inner: '#4A90E2',
|
|
115
|
+
mid: '#2C5F8A',
|
|
116
|
+
outer: '#1A3A5A'
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Sample deterministically (stride-based)
|
|
121
|
+
const stride = Math.max(1, Math.floor(validPixels.length / SAMPLE_MAX_PIXELS));
|
|
122
|
+
const sampled = validPixels.filter((_, i) => i % stride === 0);
|
|
123
|
+
|
|
124
|
+
// Sort by vibrancy (saturation × luminance) for inner color
|
|
125
|
+
const sortedByVibrancy = [...sampled].sort(
|
|
126
|
+
(a, b) => (b.saturation * b.luminance) - (a.saturation * a.luminance)
|
|
127
|
+
);
|
|
128
|
+
const innerPixel = sortedByVibrancy[0] || sampled[0];
|
|
129
|
+
|
|
130
|
+
// Find median luminance for mid color
|
|
131
|
+
const sortedByLuminance = [...sampled].sort((a, b) => a.luminance - b.luminance);
|
|
132
|
+
const medianIndex = Math.floor(sampled.length / 2);
|
|
133
|
+
const midPixel = sortedByLuminance[medianIndex] || sampled[0];
|
|
134
|
+
|
|
135
|
+
// Find dark anchor for outer color
|
|
136
|
+
const darkPixels = sampled.filter(p => p.luminance < 100 && p.luminance > 10);
|
|
137
|
+
const outerPixel = darkPixels.length > 0
|
|
138
|
+
? darkPixels.sort((a, b) => a.luminance - b.luminance)[0]
|
|
139
|
+
: sortedByLuminance[0] || sampled[0];
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
inner: rgbToHex(innerPixel.r, innerPixel.g, innerPixel.b),
|
|
143
|
+
mid: rgbToHex(midPixel.r, midPixel.g, midPixel.b),
|
|
144
|
+
outer: rgbToHex(outerPixel.r, outerPixel.g, outerPixel.b)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Draw radial gradient column
|
|
150
|
+
*/
|
|
151
|
+
function drawRadialGradientColumn(ctx, x, y, width, height, palette) {
|
|
152
|
+
const centerX = x + width / 2;
|
|
153
|
+
const centerY = y + height / 2;
|
|
154
|
+
const radius = Math.sqrt(
|
|
155
|
+
Math.pow(Math.max(centerX - x, x + width - centerX), 2) +
|
|
156
|
+
Math.pow(Math.max(centerY - y, y + height - centerY), 2)
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const gradient = ctx.createRadialGradient(
|
|
160
|
+
centerX, centerY, 0,
|
|
161
|
+
centerX, centerY, radius
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
gradient.addColorStop(0, palette.inner);
|
|
165
|
+
gradient.addColorStop(INNER_STOP, palette.mid);
|
|
166
|
+
gradient.addColorStop(1, palette.outer);
|
|
167
|
+
|
|
168
|
+
ctx.fillStyle = gradient;
|
|
169
|
+
ctx.fillRect(x, y, width, height);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Draw deterministic noise texture overlay
|
|
174
|
+
*/
|
|
175
|
+
function drawNoiseTexture(ctx, width, height) {
|
|
176
|
+
const existingData = ctx.getImageData(0, 0, width, height);
|
|
177
|
+
const existing = existingData.data;
|
|
178
|
+
|
|
179
|
+
for (let y = 0; y < height; y++) {
|
|
180
|
+
for (let x = 0; x < width; x++) {
|
|
181
|
+
const index = (y * width + x) * 4;
|
|
182
|
+
const noiseValue = (hash(x, y) % 256) / 255;
|
|
183
|
+
const gray = Math.floor(noiseValue * 255);
|
|
184
|
+
const alpha = noiseValue * NOISE_STRENGTH;
|
|
185
|
+
|
|
186
|
+
existing[index] = Math.floor(gray * alpha + existing[index] * (1 - alpha));
|
|
187
|
+
existing[index + 1] = Math.floor(gray * alpha + existing[index + 1] * (1 - alpha));
|
|
188
|
+
existing[index + 2] = Math.floor(gray * alpha + existing[index + 2] * (1 - alpha));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
ctx.putImageData(existingData, 0, 0);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Draw center highlight band
|
|
197
|
+
*/
|
|
198
|
+
function drawCenterHighlight(ctx, width, height) {
|
|
199
|
+
const centerX = width / 2;
|
|
200
|
+
const halfWidth = HIGHLIGHT_WIDTH / 2;
|
|
201
|
+
|
|
202
|
+
const gradient = ctx.createLinearGradient(
|
|
203
|
+
centerX - halfWidth, 0,
|
|
204
|
+
centerX + halfWidth, 0
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
gradient.addColorStop(0, `rgba(255, 255, 255, 0)`);
|
|
208
|
+
gradient.addColorStop(0.5, `rgba(255, 255, 255, ${HIGHLIGHT_ALPHA})`);
|
|
209
|
+
gradient.addColorStop(1, `rgba(255, 255, 255, 0)`);
|
|
210
|
+
|
|
211
|
+
ctx.fillStyle = gradient;
|
|
212
|
+
ctx.fillRect(centerX - halfWidth, 0, HIGHLIGHT_WIDTH, height);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Draw image with contain fit (maintains aspect ratio, centered)
|
|
217
|
+
*/
|
|
218
|
+
function drawContain(ctx, img, centerX, centerY, maxWidth, maxHeight) {
|
|
219
|
+
const imgAspect = img.width / img.height;
|
|
220
|
+
const maxAspect = maxWidth / maxHeight;
|
|
221
|
+
|
|
222
|
+
let drawWidth, drawHeight;
|
|
223
|
+
|
|
224
|
+
if (imgAspect > maxAspect) {
|
|
225
|
+
drawWidth = maxWidth;
|
|
226
|
+
drawHeight = maxWidth / imgAspect;
|
|
227
|
+
} else {
|
|
228
|
+
drawHeight = maxHeight;
|
|
229
|
+
drawWidth = maxHeight * imgAspect;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const x = centerX - drawWidth / 2;
|
|
233
|
+
const y = centerY - drawHeight / 2;
|
|
234
|
+
|
|
235
|
+
ctx.drawImage(img, x, y, drawWidth, drawHeight);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Base URL for loading logos - can be the SPA's public folder or S3
|
|
239
|
+
const LOGO_BASE_URL = process.env.MATCHUP_LOGO_BASE_URL || 'https://dubs.app';
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Team name mappings for cases where TheSportsDB name differs from our file names
|
|
243
|
+
* Key: TheSportsDB team name (lowercase)
|
|
244
|
+
* Value: Our file name (without extension)
|
|
245
|
+
*/
|
|
246
|
+
const TEAM_NAME_OVERRIDES = {
|
|
247
|
+
// EPL - Map API names to local file names
|
|
248
|
+
'bournemouth': 'afc_bournemouth',
|
|
249
|
+
'brighton': 'brighton_and_hove_albion',
|
|
250
|
+
'brighton & hove albion': 'brighton_and_hove_albion',
|
|
251
|
+
'brighton and hove albion': 'brighton_and_hove_albion',
|
|
252
|
+
'man city': 'manchester_city',
|
|
253
|
+
'man utd': 'manchester_united',
|
|
254
|
+
'wolves': 'wolverhampton_wanderers',
|
|
255
|
+
'spurs': 'tottenham_hotspur',
|
|
256
|
+
'tottenham': 'tottenham_hotspur',
|
|
257
|
+
"nott'm forest": 'nottingham_forest',
|
|
258
|
+
'nottingham': 'nottingham_forest',
|
|
259
|
+
'leeds': 'leeds_united',
|
|
260
|
+
'newcastle': 'newcastle_united',
|
|
261
|
+
'west ham': 'west_ham_united',
|
|
262
|
+
|
|
263
|
+
// NHL - Handle Utah team rename
|
|
264
|
+
'utah hockey club': 'utah_mammoth',
|
|
265
|
+
'arizona coyotes': 'utah_mammoth',
|
|
266
|
+
|
|
267
|
+
// NBA
|
|
268
|
+
'la clippers': 'la_clippers',
|
|
269
|
+
|
|
270
|
+
// NCAAB - Map ESPN names to our file names
|
|
271
|
+
'uconn huskies': 'connecticut',
|
|
272
|
+
'uconn': 'connecticut',
|
|
273
|
+
"st. john's red storm": 'st_johns',
|
|
274
|
+
"st. john's": 'st_johns',
|
|
275
|
+
'ole miss rebels': 'mississippi',
|
|
276
|
+
'ole miss': 'mississippi',
|
|
277
|
+
'pitt panthers': 'pittsburgh',
|
|
278
|
+
'pitt': 'pittsburgh',
|
|
279
|
+
'lsu tigers': 'lsu',
|
|
280
|
+
'smu mustangs': 'southern_methodist',
|
|
281
|
+
'smu': 'southern_methodist',
|
|
282
|
+
'ucf knights': 'ucf_knights',
|
|
283
|
+
'tcu horned frogs': 'tcu',
|
|
284
|
+
'unlv rebels': 'unlv',
|
|
285
|
+
'utep miners': 'utep',
|
|
286
|
+
'vcu rams': 'vcu',
|
|
287
|
+
'uab blazers': 'uab',
|
|
288
|
+
'stanford cardinal': 'stanford',
|
|
289
|
+
'rutgers scarlet knights': 'rutgers',
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Common college mascot names to strip from team names
|
|
294
|
+
*/
|
|
295
|
+
const NCAAB_MASCOTS = [
|
|
296
|
+
'wildcats', 'bulldogs', 'tigers', 'eagles', 'hawks', 'bears', 'lions',
|
|
297
|
+
'cougars', 'huskies', 'spartans', 'wolverines', 'buckeyes', 'gophers',
|
|
298
|
+
'badgers', 'hoosiers', 'boilermakers', 'hawkeyes', 'cornhuskers',
|
|
299
|
+
'jayhawks', 'sooners', 'longhorns', 'aggies', 'red raiders', 'horned frogs',
|
|
300
|
+
'cowboys', 'razorbacks', 'volunteers', 'commodores', 'gamecocks',
|
|
301
|
+
'crimson tide', 'fighting irish', 'blue devils', 'tar heels', 'wolfpack',
|
|
302
|
+
'demon deacons', 'cavaliers', 'hokies', 'yellow jackets', 'seminoles',
|
|
303
|
+
'hurricanes', 'orange', 'cardinals', 'cardinal', 'fighting illini', 'golden gophers',
|
|
304
|
+
'nittany lions', 'mountaineers', 'panthers', 'blue jays', 'red storm',
|
|
305
|
+
'friars', 'musketeers', 'pirates', 'colonials', 'rams', 'flyers', 'explorers',
|
|
306
|
+
'billikens', 'bonnies', 'braves', 'bison', 'phoenix', 'knights', 'scarlet knights', 'owls',
|
|
307
|
+
'pilots', 'waves', 'gaels', 'dons', 'broncos', 'toreros', 'aztecs',
|
|
308
|
+
'rebels', 'utes', 'coyotes', 'anteaters', 'tritons', 'matadors', 'titans',
|
|
309
|
+
'mustangs', 'gauchos', 'hornets', 'lumberjacks', 'vandals',
|
|
310
|
+
'grizzlies', 'bobcats', 'thunderbirds', 'roadrunners', 'miners', 'mean green',
|
|
311
|
+
'blazers', 'jaguars', 'trojans', 'bruins', 'ducks', 'beavers',
|
|
312
|
+
'cougs', 'sun devils', 'buffaloes', 'falcons', 'rainbow warriors', 'warriors', 'bearkats', 'cyclones'
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Strip mascot name from NCAAB team name
|
|
317
|
+
* "Duke Blue Devils" → "duke"
|
|
318
|
+
* "Michigan State Spartans" → "michigan_state"
|
|
319
|
+
*/
|
|
320
|
+
function stripNCAABMascot(teamName) {
|
|
321
|
+
let name = teamName.toLowerCase();
|
|
322
|
+
|
|
323
|
+
// Sort by length (longest first) to match multi-word mascots first
|
|
324
|
+
const sortedMascots = [...NCAAB_MASCOTS].sort((a, b) => b.length - a.length);
|
|
325
|
+
|
|
326
|
+
for (const mascot of sortedMascots) {
|
|
327
|
+
if (name.endsWith(' ' + mascot)) {
|
|
328
|
+
name = name.slice(0, name.length - mascot.length - 1).trim();
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return name;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Get team badge URL from team name
|
|
338
|
+
* Uses remote URL (SPA or CDN) rather than local file
|
|
339
|
+
*/
|
|
340
|
+
function getTeamBadgeUrl(teamName, league) {
|
|
341
|
+
const lowerName = teamName.trim().toLowerCase();
|
|
342
|
+
|
|
343
|
+
// Check for overrides first
|
|
344
|
+
if (TEAM_NAME_OVERRIDES[lowerName]) {
|
|
345
|
+
return `${LOGO_BASE_URL}/major_league_logos/${league}/${TEAM_NAME_OVERRIDES[lowerName]}.png`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// For NCAAB, strip mascot names (e.g., "Duke Blue Devils" → "duke")
|
|
349
|
+
if (league === 'NCAAB') {
|
|
350
|
+
const strippedName = stripNCAABMascot(teamName);
|
|
351
|
+
const formattedName = strippedName
|
|
352
|
+
.toLowerCase()
|
|
353
|
+
.split(' ').join('_')
|
|
354
|
+
.replace(/[\.,?!']/g, '')
|
|
355
|
+
.replace(/&/g, 'and');
|
|
356
|
+
return `${LOGO_BASE_URL}/major_league_logos/NCAAB/${formattedName}.png`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// For UFC, find fighter file (handles partial names like "Adesanya" → "israel_adesanya")
|
|
360
|
+
if (league === 'UFC') {
|
|
361
|
+
const formattedName = findUFCFighterFile(teamName);
|
|
362
|
+
return `${LOGO_BASE_URL}/major_league_logos/UFC/${formattedName}.png`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Default formatting: lowercase, spaces to underscores, remove punctuation
|
|
366
|
+
const formattedName = lowerName.split(' ').join('_').replace(/[\.,?!]/g, '');
|
|
367
|
+
return `${LOGO_BASE_URL}/major_league_logos/${league}/${formattedName}.png`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Normalize UFC fighter name to match local file name
|
|
372
|
+
* "Sean Strickland" → "sean_strickland"
|
|
373
|
+
*/
|
|
374
|
+
function normalizeUFCFighterName(name) {
|
|
375
|
+
return name
|
|
376
|
+
.toLowerCase()
|
|
377
|
+
.replace(/\s+/g, '_')
|
|
378
|
+
.replace(/'/g, '')
|
|
379
|
+
.replace(/\./g, '')
|
|
380
|
+
.replace(/-/g, '_');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Find UFC fighter file by partial match
|
|
385
|
+
* Handles cases where only last name is provided (e.g., "Adesanya" → "israel_adesanya.png")
|
|
386
|
+
*/
|
|
387
|
+
function findUFCFighterFile(searchName) {
|
|
388
|
+
const normalizedSearch = normalizeUFCFighterName(searchName);
|
|
389
|
+
const ufcDir = path.join(__dirname, '../../../dubs-jackpot-spa/public/major_league_logos/UFC');
|
|
390
|
+
|
|
391
|
+
try {
|
|
392
|
+
// First try exact match
|
|
393
|
+
const exactPath = path.join(ufcDir, `${normalizedSearch}.png`);
|
|
394
|
+
if (fs.existsSync(exactPath)) {
|
|
395
|
+
return normalizedSearch;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// If not found, search for files containing the search term
|
|
399
|
+
const files = fs.readdirSync(ufcDir);
|
|
400
|
+
for (const file of files) {
|
|
401
|
+
if (file.endsWith('.png') && file.includes(normalizedSearch)) {
|
|
402
|
+
// Return filename without extension
|
|
403
|
+
return file.replace('.png', '');
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
console.log(`[MatchupImage:UFC] No file found for fighter: ${searchName} (searched: ${normalizedSearch})`);
|
|
408
|
+
return normalizedSearch; // Return original as fallback
|
|
409
|
+
} catch (err) {
|
|
410
|
+
console.error(`[MatchupImage:UFC] Error searching for fighter file:`, err.message);
|
|
411
|
+
return normalizedSearch;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Get league logo URL
|
|
417
|
+
* Returns null for leagues without a league logo (NCAAB, NCAAF, UFC)
|
|
418
|
+
*/
|
|
419
|
+
function getLeagueLogoUrl(league) {
|
|
420
|
+
const leagueLogos = {
|
|
421
|
+
'NHL': 'nhl.png',
|
|
422
|
+
'NBA': 'nba.png',
|
|
423
|
+
'NFL': 'nfl.png',
|
|
424
|
+
'MLB': 'mlb.png',
|
|
425
|
+
'EPL': 'epl.png'
|
|
426
|
+
};
|
|
427
|
+
// Some leagues don't have league logos - return null for them
|
|
428
|
+
if (!leagueLogos[league]) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
return `${LOGO_BASE_URL}/major_league_logos/${league}/${leagueLogos[league]}`;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Load image from URL with retry logic
|
|
436
|
+
*/
|
|
437
|
+
async function safeLoadImage(url, retries = 2) {
|
|
438
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
439
|
+
try {
|
|
440
|
+
console.log(`[MatchupImage] Loading: ${url} (attempt ${attempt + 1})`);
|
|
441
|
+
const img = await loadImage(url);
|
|
442
|
+
return img;
|
|
443
|
+
} catch (err) {
|
|
444
|
+
console.warn(`[MatchupImage] Failed to load: ${url}`, err.message);
|
|
445
|
+
if (attempt < retries) {
|
|
446
|
+
await new Promise(r => setTimeout(r, 500 * (attempt + 1))); // Exponential backoff
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Generate matchup image for a game
|
|
455
|
+
*
|
|
456
|
+
* @param {Object} options
|
|
457
|
+
* @param {string} options.homeTeam - Home team name (e.g., "Toronto Maple Leafs" or "Sean Strickland")
|
|
458
|
+
* @param {string} options.awayTeam - Away team name (e.g., "Florida Panthers" or "Anthony Hernandez")
|
|
459
|
+
* @param {string} options.league - League abbreviation (NHL, NBA, NFL, MLB, UFC, NCAAB)
|
|
460
|
+
* @param {number} [options.width=600] - Output width
|
|
461
|
+
* @param {number} [options.height=315] - Output height
|
|
462
|
+
*
|
|
463
|
+
* @returns {Promise<{buffer: Buffer, dataUrl: string, palettes: Object}>}
|
|
464
|
+
*/
|
|
465
|
+
async function generateMatchupImage(options) {
|
|
466
|
+
const {
|
|
467
|
+
homeTeam,
|
|
468
|
+
awayTeam,
|
|
469
|
+
league = 'NHL',
|
|
470
|
+
width = DEFAULT_WIDTH,
|
|
471
|
+
height = DEFAULT_HEIGHT
|
|
472
|
+
} = options;
|
|
473
|
+
|
|
474
|
+
// EPL and UFC use "Home vs Away" convention (Home on left), US sports use "Away @ Home" (Away on left)
|
|
475
|
+
const leagueUpper = league?.toUpperCase();
|
|
476
|
+
const isHomeFirst = leagueUpper === 'EPL' || leagueUpper === 'UFC';
|
|
477
|
+
const leftTeam = isHomeFirst ? homeTeam : awayTeam;
|
|
478
|
+
const rightTeam = isHomeFirst ? awayTeam : homeTeam;
|
|
479
|
+
|
|
480
|
+
console.log(`[MatchupImage] Generating: ${leftTeam} vs ${rightTeam} (${league}) [HomeFirst=${isHomeFirst}]`);
|
|
481
|
+
|
|
482
|
+
// Create canvas
|
|
483
|
+
const canvas = createCanvas(width, height);
|
|
484
|
+
const ctx = canvas.getContext('2d');
|
|
485
|
+
|
|
486
|
+
// Construct logo URLs from team/fighter names (same for all leagues now)
|
|
487
|
+
const leftLogoUrl = getTeamBadgeUrl(leftTeam, league);
|
|
488
|
+
const rightLogoUrl = getTeamBadgeUrl(rightTeam, league);
|
|
489
|
+
const leagueLogoUrl = getLeagueLogoUrl(league);
|
|
490
|
+
|
|
491
|
+
console.log(`[MatchupImage] Loading logos:`, { leftLogoUrl, rightLogoUrl, leagueLogoUrl });
|
|
492
|
+
|
|
493
|
+
const [leftImg, rightImg, leagueImg] = await Promise.all([
|
|
494
|
+
safeLoadImage(leftLogoUrl),
|
|
495
|
+
safeLoadImage(rightLogoUrl),
|
|
496
|
+
leagueLogoUrl ? safeLoadImage(leagueLogoUrl) : Promise.resolve(null)
|
|
497
|
+
]);
|
|
498
|
+
|
|
499
|
+
// Default palettes for when logos fail to load
|
|
500
|
+
const defaultLeftPalette = { inner: '#4A90E2', mid: '#2C5F8A', outer: '#1A3A5A' };
|
|
501
|
+
const defaultRightPalette = { inner: '#E24A4A', mid: '#8A2C2C', outer: '#5A1A1A' };
|
|
502
|
+
|
|
503
|
+
// NCAAB: use white/light-gray backgrounds so single-color college logos stay visible
|
|
504
|
+
const whitePalette = { inner: '#FFFFFF', mid: '#F5F5F5', outer: '#EBEBEB' };
|
|
505
|
+
const grayPalette = { inner: '#ECEEF4', mid: '#E2E4EC', outer: '#D8DAE2' };
|
|
506
|
+
|
|
507
|
+
// Extract palettes from logos (skip for NCAAB - use white)
|
|
508
|
+
let leftPalette = defaultLeftPalette;
|
|
509
|
+
let rightPalette = defaultRightPalette;
|
|
510
|
+
|
|
511
|
+
if (leagueUpper === 'NCAAB') {
|
|
512
|
+
leftPalette = whitePalette;
|
|
513
|
+
rightPalette = grayPalette;
|
|
514
|
+
} else {
|
|
515
|
+
if (leftImg) {
|
|
516
|
+
leftPalette = extractLogoPalette(leftImg, canvas, ctx);
|
|
517
|
+
}
|
|
518
|
+
if (rightImg) {
|
|
519
|
+
rightPalette = extractLogoPalette(rightImg, canvas, ctx);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Draw left column gradient (away team)
|
|
524
|
+
const columnWidth = width / 2;
|
|
525
|
+
drawRadialGradientColumn(ctx, 0, 0, columnWidth, height, leftPalette);
|
|
526
|
+
|
|
527
|
+
// Draw right column gradient (home team)
|
|
528
|
+
drawRadialGradientColumn(ctx, columnWidth, 0, columnWidth, height, rightPalette);
|
|
529
|
+
|
|
530
|
+
// Draw noise texture
|
|
531
|
+
drawNoiseTexture(ctx, width, height);
|
|
532
|
+
|
|
533
|
+
// Draw center highlight
|
|
534
|
+
drawCenterHighlight(ctx, width, height);
|
|
535
|
+
|
|
536
|
+
// Draw team logos
|
|
537
|
+
if (leftImg) {
|
|
538
|
+
drawContain(ctx, leftImg, columnWidth / 2, height / 2, TEAM_LOGO_MAX_SIZE, TEAM_LOGO_MAX_SIZE);
|
|
539
|
+
}
|
|
540
|
+
if (rightImg) {
|
|
541
|
+
drawContain(ctx, rightImg, columnWidth + columnWidth / 2, height / 2, TEAM_LOGO_MAX_SIZE, TEAM_LOGO_MAX_SIZE);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Draw league logo centered on seam
|
|
545
|
+
if (leagueImg) {
|
|
546
|
+
drawContain(ctx, leagueImg, width / 2, height / 2, LEAGUE_LOGO_MAX_SIZE, LEAGUE_LOGO_MAX_SIZE);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Export as PNG buffer
|
|
550
|
+
const buffer = canvas.toBuffer('image/png');
|
|
551
|
+
const dataUrl = canvas.toDataURL('image/png');
|
|
552
|
+
|
|
553
|
+
console.log(`[MatchupImage] Generated image: ${buffer.length} bytes`);
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
buffer,
|
|
557
|
+
dataUrl,
|
|
558
|
+
palettes: {
|
|
559
|
+
left: leftPalette,
|
|
560
|
+
right: rightPalette
|
|
561
|
+
},
|
|
562
|
+
size: { width, height }
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Supported leagues for matchup image generation
|
|
568
|
+
*/
|
|
569
|
+
const SUPPORTED_LEAGUES = ['NHL', 'NBA', 'NFL', 'MLB', 'EPL', 'NCAAB', 'UFC'];
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Check if a league is supported for matchup images
|
|
573
|
+
*/
|
|
574
|
+
function isLeagueSupported(league) {
|
|
575
|
+
return SUPPORTED_LEAGUES.includes(league?.toUpperCase());
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Check if we can generate a matchup image for a game
|
|
580
|
+
* (We can generate for any game with team names and a supported league)
|
|
581
|
+
*/
|
|
582
|
+
function canGenerateMatchupImage(homeTeam, awayTeam, league) {
|
|
583
|
+
return !!(homeTeam && awayTeam && isLeagueSupported(league));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
module.exports = {
|
|
587
|
+
generateMatchupImage,
|
|
588
|
+
isLeagueSupported,
|
|
589
|
+
canGenerateMatchupImage
|
|
590
|
+
};
|
|
591
|
+
|