PageSourceSearch

https://wushixian.cc/static/js/modules/StyleAnalyzer.js

js wushixian.cc collected 2026-10-10 02:22:35 UTC 17,360 bytes, 413 lines download raw bytes

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.