1import { API } from '/static/js/modules/api.js'; 2 3const ANALYSIS_IMAGE_MAX_DIMENSION = 1280; 4const ANALYSIS_IMAGE_REENCODE_THRESHOLD = 1024 * 1024; 5const ANALYSIS_IMAGE_TARGET_SIZE = 1024 * 1024; 6const ANALYSIS_IMAGE_QUALITIES = [0.84, 0.74, 0.64, 0.54]; 7 8export class StyleAnalyzer { 9 constructor() { 10 // Main Modal 11 this.styleModal = document.getElementById('style-modal'); 12 if (!this.styleModal) return; // If modal doesn't exist, do nothing 13 14 // UI Elements 15 this.styleModalTitle = this.styleModal.querySelector('h3'); 16 this.styleStepUpload = document.getElementById('style-step-upload'); 17 this.styleStepLoading = document.getElementById('style-step-loading'); 18 this.styleLoadingText = document.getElementById('style-loading-text'); 19 this.styleStepResult = document.getElementById('style-step-result'); 20 this.styleUploadPreview = document.getElementById('style-upload-preview'); 21 this.styleUploadPlaceholder = document.getElementById('style-upload-placeholder'); 22 this.startAnalyzeBtn = document.getElementById('start-analyze-btn'); 23 this.styleReuploadBtn = document.getElementById('style-reupload-btn'); 24 this.styleResultDesc = document.getElementById('style-result-desc'); 25 this.copyResultBtnMain = document.getElementById('copy-result-btn-main'); 26 this.styleModalFooter = document.getElementById('style-modal-footer'); 27 this.styleModalImg = document.getElementById('style-modal-img'); 28 this.styleJsonContent = document.getElementById('style-json-content'); 29 this.copyJsonBtn = document.getElementById('copy-json-btn'); 30 this.analyzeInput = document.getElementById('analyze-input'); 31 32 // State 33 this.currentAnalyzeImage = null; 34 this.currentButtonId = null; 35 this.isAnalyzing = false; 36 37 this.bindEvents(); 38 } 39 40 bindEvents() { 41 // Bind internal modal events 42 if (this.analyzeInput) { 43 this.analyzeInput.addEventListener('change', async (e) => { 44 const file = e.target.files[0]; 45 if (!file) return; 46 if (!file.type.startsWith('image/')) { 47 this.dispatchEvent('show-toast', { message: '请ä¸ä¼ å¾çæä»¶', type: 'warning' }); 48 return; 49 } 50 try { 51 const base64 = await this.processImage(file); 52 this.updateStyleModalPreview(base64); 53 } catch (e) { 54 this.dispatchEvent('show-toast', { message: e.message, type: 'error' }); 55 } finally { 56 this.analyzeInput.value = ''; 57 } 58 }); 59 } 60 61 if (this.startAnalyzeBtn) { 62 this.startAnalyzeBtn.addEventListener('click', () => this.performAnalysis()); 63 } 64 65 const uploadZone = document.getElementById('style-step-upload'); 66 if (uploadZone) { 67 uploadZone.addEventListener('dragover', (e) => { 68 e.preventDefault(); 69 uploadZone.classList.add('bg-purple-50', 'border-purple-300'); 70 }); 71 uploadZone.addEventListener('dragleave', (e) => { 72 e.preventDefault(); 73 uploadZone.classList.remove('bg-purple-50', 'border-purple-300'); 74 }); 75 uploadZone.addEventListener('drop', async (e) => { 76 e.preventDefault(); 77 e.stopPropagation(); 78 this.dispatchEvent('app:drag-reset', {}); 79 uploadZone.classList.remove('bg-purple-50', 'border-purple-300'); 80 const files = e.dataTransfer.files; 81 if (files.length > 0 && files[0].type.startsWith('image/')) { 82 try { 83 const base64 = await this.processImage(files[0]); 84 this.updateStyleModalPreview(base64); 85 } catch (err) { 86 this.dispatchEvent('show-toast', { message: err.message, type: 'error' }); 87 } 88 } 89 }); 90 } 91 } 92 93 async performAnalysis() { 94 const base64 = this.currentAnalyzeImage; 95 if (!base64 || !this.currentButtonId || this.isAnalyzing) return; 96 97 this.isAnalyzing = true; 98 try { 99 this.setStyleModalLoading(true); 100 const result = await API.analyzeStyle(base64, this.currentButtonId);
101 this.showStyleModalResult(base64, result.analysis); 102 this.dispatchEvent('analysis-complete', { credits: result.credits_remaining }); 103 } catch (error) { 104 console.error("Style analysis failed", error); 105 this.dispatchEvent('show-toast', { message: `åæå¤±è´¥: ${error.message}`, type: 'error' }); 106 this.setStyleModalLoading(false); 107 this.styleStepUpload.classList.remove('hidden'); 108 this.styleModalFooter.classList.remove('hidden'); 109 this.startAnalyzeBtn.disabled = false; 110 this.startAnalyzeBtn.classList.remove('opacity-50', 'cursor-not-allowed'); 111 } finally { 112 this.isAnalyzing = false; 113 } 114 } 115 116 async analyzeGeneratedImage(base64) { 117 if (!base64 || this.isAnalyzing) return; 118 119 this.isAnalyzing = true; 120 try { 121 this.resetStyleModal(); 122 this.currentAnalyzeImage = base64; 123 this.currentButtonId = null; 124 125 const titleEl = this.styleModal.querySelector('#style-modal-title span'); 126 const descriptionEl = this.styleModal.querySelector('#style-modal-description p:first-child'); 127 128 if (titleEl) { 129 titleEl.textContent = 'JSONåæ'; 130 } 131 if (descriptionEl) { 132 descriptionEl.textContent = 'æ£å¨åæè¿å¼ çæå¾çã'; 133 } 134 135 this.styleModal.classList.remove('hidden'); 136 this.styleStepUpload.classList.add('hidden'); 137 this.styleModalFooter.classList.add('hidden'); 138 this.setStyleModalLoading(true, 'æ£å¨åæçæå¾ç...'); 139 140 const result = await API.analyzeGeneratedImage(base64); 141 this.showStyleModalResult(base64, result.analysis, { 142 description: 'JSONåæå·²å®æï¼å¯å¤å¶ç»æç¨äºåç»æç¤ºè¯æ´çã' 143 }); 144 this.dispatchEvent('analysis-complete', { credits: result.credits_remaining }); 145 } catch (error) { 146 console.error("Generated image analysis failed", error); 147 this.dispatchEvent('show-toast', { message: `åæå¤±è´¥: ${error.message}`, type: 'error' }); 148 this.setStyleModalLoading(false); 149 this.resetStyleModal(); 150 this.styleModal.classList.add('hidden'); 151 } finally { 152 this.isAnalyzing = false; 153 } 154 } 155 156 showStyleAnalyzerModal(buttonId, title, description) { 157 this.currentButtonId = buttonId; 158 this.resetStyleModal(); 159 this.styleModal.classList.remove('hidden'); 160 161 const titleEl = this.styleModal.querySelector('#style-modal-title span'); 162 const descriptionEl = this.styleModal.querySelector('#style-modal-description p:first-child'); 163 164 if (titleEl) { 165 titleEl.textContent = title || 'åæ'; 166 } 167 if (descriptionEl) { 168 descriptionEl.textContent = description || 'ä¸ä¼ å¾çå¼å§åæã'; 169 } 170 } 171 172 resetStyleModal() { 173 this.styleStepUpload.classList.remove('hidden'); 174 this.styleStepLoading.classList.add('hidden'); 175 this.styleStepResult.classList.add('hidden'); 176 this.styleModalFooter.classList.remove('hidden'); 177 this.styleUploadPreview.classList.add('hidden'); 178 this.styleUploadPreview.src = ''; 179 this.styleUploadPlaceholder.classList.remove('hidden'); 180 this.startAnalyzeBtn.disabled = true; 181 this.startAnalyzeBtn.classList.add('opacity-50', 'cursor-not-allowed'); 182 this.currentAnalyzeImage = null; 183 } 184 185 updateStyleModalPreview(base64) { 186 this.currentAnalyzeImage = base64; 187 this.styleUploadPreview.src = base64; 188 this.styleUploadPreview.classList.remove('hidden'); 189 this.styleUploadPlaceholder.classList.add('hidden'); 190 this.startAnalyzeBtn.disabled = false; 191 this.startAnalyzeBtn.classList.remove('opacity-50', 'cursor-not-allowed'); 192 } 193 194 setStyleModalLoading(loading, text = 'æ£å¨åæå¾ç...') { 195 if (loading) { 196 if (this.styleLoadingText) { 197 this.styleLoadingText.textContent = text; 198 } 199 this.styleStepLoading.classList.remove('hidden'); 200 this.styleModalFooter.classList.add('hidden'); 201 } else { 202 this.styleStepLoading.classList.add('hidden'); 203 } 204 } 205 206 showStyleModalResult(base64Img, analysisResult, options = {}) { 207 this.styleStepLoading.classList.add('hidden'); 208 this.styleStepUpload.classList.add('hidden'); 209 this.styleModalFooter.classList.add('hidden'); 210 this.styleStepResult.classList.remove('hidden'); 211 this.styleModalImg.src = base64Img; 212 213 if (this.styleResultDesc) { 214 this.styleResultDesc.textContent = options.description || `ä»»å¡å·²å®æï¼å¯å¤å¶æä¿®æ¹ç»æï¼å å ¥çå¾æç¤ºè¯ã`; 215 } 216 217 let resultText; 218 // The backend now returns an object that is either the analysis result itself, 219 // or an object with a `raw_content` key if it was not val
219id JSON. 220 if (analysisResult && typeof analysisResult === 'object') { 221 if (analysisResult.raw_content) { 222 resultText = this.localizeAnalysisText(analysisResult.raw_content); 223 } else { 224 resultText = JSON.stringify(this.localizeAnalysisKeys(analysisResult), null, 2); 225 } 226 } else { 227 // Fallback for unexpected formats 228 resultText = String(analysisResult); 229 } 230 231 this.styleJsonContent.textContent = resultText; 232 233 if (this.copyJsonBtn) { 234 this.copyJsonBtn.onclick = () => this.dispatchEvent('request-copy', { text: resultText }); 235 } 236 if (this.copyResultBtnMain) { 237 this.copyResultBtnMain.onclick = () => this.dispatchEvent('request-copy', { text: resultText }); 238 } 239 if (this.styleReuploadBtn) { 240 this.styleReuploadBtn.onclick = () => this.resetStyleModal(); 241 } 242 } 243 244 localizeAnalysisText(text) { 245 const cleanedText = String(text) 246 .replaceAll('```json', '') 247 .replaceAll('```JSON', '') 248 .replaceAll('```', '') 249 .replaceAll('"expression_style"', '"表达æ¹å¼"') 250 .replaceAll('"environment"', '"ç¯å¢è¡¨è¾¾"') 251 .replaceAll('"weather"', '"天æ°è¡¨è¾¾"') 252 .replaceAll('"material"', '"æè´¨è¡¨ç°"') 253 .replaceAll('"composition"', '"è§åº¦æå¾"') 254 .replaceAll('"category"', '"å¾çç±»å«"') 255 .replaceAll('"analysis"', '"åæ"') 256 .replaceAll('"subject"', '"主ä½"') 257 .replaceAll('"style"', '"飿 ¼"') 258 .replaceAll('"visual_expression"', '"è¡¨è¾¾é£æ ¼"') 259 .replaceAll('"color"', '"è²å½©"') 260 .replaceAll('"mood"', '"æ°å´"') 261 .replaceAll(': true', ': "æ¯"') 262 .replaceAll(': false', ': "å¦"') 263 .trim(); 264 265 try { 266 return JSON.stringify(this.localizeAnalysisKeys(JSON.parse(cleanedText)), null, 2); 267 } catch (_) { 268 return cleanedText 269 .replace(/^\s*"is_architecture_rendering"\s*:\s*("[^"]*"|true|false)\s*,?\s*$/gmi, '') 270 .replace(/^\s*"æ¯å¦å»ºçææå¾"\s*:\s*("[^"]*"|true|false)\s*,?\s*$/gmi, '') 271 .replace(/,\s*\n\s*}/g, '\n}') 272 .trim(); 273 } 274 } 275 276 localizeAnalysisKeys(value) { 277 const keyMap = { 278 color: 'è²å½©', 279 expression_style: '表达æ¹å¼', 280 environment: 'ç¯å¢è¡¨è¾¾', 281 weather: '天æ°è¡¨è¾¾', 282 material: 'æè´¨è¡¨ç°', 283 composition: 'è§åº¦æå¾', 284 category: 'å¾çç±»å«', 285 analysis: 'åæ', 286 subject: '主ä½', 287 style: '飿 ¼', 288 visual_expression: 'è¡¨è¾¾é£æ ¼', 289 mood: 'æ°å´', 290 raw_content: 'åå§å 容' 291 }; 292 293 if (Array.isArray(value)) { 294 return value.map(item => this.localizeAnalysisKeys(item)); 295 } 296 if (typeof value === 'boolean') { 297 return value ? 'æ¯' : 'å¦'; 298 } 299 if (!value || typeof value !== 'object') { 300 return value; 301 } 302 303 return Object.entries(value).reduce((acc, [key, item]) => { 304 if (key === 'is_architecture_rendering' || key === 'æ¯å¦å»ºçææå¾') { 305 return acc; 306 } 307 const localizedKey = keyMap[key] || key; 308 acc[localizedKey] = this.localizeAnalysisKeys(item); 309 return acc; 310 }, {}); 311 } 312 313 processImage(file) { 314 return new Promise((resolve, reject) => { 315 const reader = new FileReader(); 316 reader.onload = (e) => { 317 const img = new Image(); 318 img.onload = () => { 319 try { 320 const sourceWidth = img.naturalWidth || img.width; 321 const sourceHeight = img.naturalHeight || img.height; 322 const scale = Math.min( 323 1, 324 ANALYSIS_IMAGE_MAX_DIMENSION / Math.max(sourceWidth, sourceHeight) 325 ); 326 const width = Math.max(1, Math.round(sourceWidth * scale)); 327 const height = Math.max(1, Math.round(sourceHeight * scale)); 328 const fileType = (file.type || '').toLowerCase(); 329 const canKeepOriginal = ( 330 scale === 1 331 && file.size <= ANALYSIS_IMAGE_REENCODE_THRESHOLD 332 && (fileType === 'image/jpeg' || fileType === 'image/webp') 333 ); 334 335 if (canKeepOriginal) { 336 resolve(e.target.result); 337 return; 338 } 339 340 const canvas = document.createElement('canvas'); 341 canvas.width = width; 342 canvas.height = height; 343 const ctx = canvas.getContext('2d'); 344 if (!ctx) { 345 throw new Error('æµè§å¨æ æ³å¤ç该å¾ç'); 346 } 347 ctx.imageSmoothingEnabled = true; 348 ctx.imageSmoothingQuality = 'high'; 349 ctx.drawImage(img, 0, 0, width, height); 350 351 resolve(this.encodeAnalysisCanvas(canvas, ctx)); 352 } catch (error) { 353 reject(new Error(error?.message || 'å¾çå缩失败')); 354 } 355 }; 356 img.onerror = () => reject(new Error("å¾çå 载失败")); 357 img.src = e.target.result; 358 }; 359 reader.onerror = () => reject(new Error("æä»¶è¯»å失败"));
360 reader.readAsDataURL(file); 361 }); 362 } 363 364 encodeAnalysisCanvas(canvas, ctx) { 365 let webpResult = null; 366 for (const quality of ANALYSIS_IMAGE_QUALITIES) { 367 const candidate = canvas.toDataURL('image/webp', quality); 368 // Browsers without WebP encoding support commonly return PNG here. 369 if (!candidate.startsWith('data:image/webp')) { 370 webpResult = null; 371 break; 372 } 373 webpResult = candidate; 374 if (this.getDataUrlSize(candidate) <= ANALYSIS_IMAGE_TARGET_SIZE) { 375 return candidate; 376 } 377 } 378 if (webpResult) { 379 return webpResult; 380 } 381 382 // JPEG has no alpha channel. Put white behind transparent pixels before 383 // falling back so transparent uploads do not become black backgrounds. 384 ctx.save(); 385 ctx.globalCompositeOperation = 'destination-over'; 386 ctx.fillStyle = '#ffffff'; 387 ctx.fillRect(0, 0, canvas.width, canvas.height); 388 ctx.restore(); 389 390 let jpegResult = null; 391 for (const quality of ANALYSIS_IMAGE_QUALITIES) { 392 jpegResult = canvas.toDataURL('image/jpeg', quality); 393 if (this.getDataUrlSize(jpegResult) <= ANALYSIS_IMAGE_TARGET_SIZE) { 394 break; 395 } 396 } 397 if (!jpegResult?.startsWith('data:image/jpeg')) { 398 throw new Error('å¾çå缩失败'); 399 } 400 return jpegResult; 401 } 402 403 getDataUrlSize(dataUrl) { 404 const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1); 405 const padding = base64.endsWith('==') ? 2 : (base64.endsWith('=') ? 1 : 0); 406 return Math.max(0, Math.floor((base64.length * 3) / 4) - padding); 407 } 408 409 dispatchEvent(eventName, detail) { 410 const event = new CustomEvent(eventName, { detail }); 411 document.dispatchEvent(event); 412 } 413}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.