seawolf2357 commited on
Commit
1f54339
·
verified ·
1 Parent(s): e006e27

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +888 -1084
app.py CHANGED
@@ -1,1103 +1,907 @@
1
- """
2
- HWP 파일 변환기 + LLM 채팅 - Gradio 웹 앱
3
- - Tab 1: LLM 채팅 (스트리밍, 파일 첨부 지원)
4
- - Tab 2: HWP 변환기
5
- """
6
- import gradio as gr
7
- import tempfile
8
  import os
9
- import subprocess
10
- import shutil
11
- import sys
12
- import re
13
  import json
14
- import uuid
15
- import sqlite3
16
- import base64
17
  import requests
18
- import zlib
19
- import zipfile
20
- from pathlib import Path
21
- from datetime import datetime
22
- from typing import Generator, List, Dict, Optional
23
- from xml.etree import ElementTree as ET
24
-
25
- # ============== 환경 설정 ==============
26
- SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
27
- PYHWP_PATH = os.path.join(SCRIPT_DIR, 'pyhwp')
28
- DB_PATH = os.path.join(SCRIPT_DIR, 'chat_history.db')
29
-
30
- if os.path.exists(PYHWP_PATH):
31
- sys.path.insert(0, PYHWP_PATH)
32
-
33
- # ============== 모듈 임포트 ==============
34
- try:
35
- import olefile
36
- OLEFILE_AVAILABLE = True
37
- print("olefile loaded successfully")
38
- except ImportError:
39
- OLEFILE_AVAILABLE = False
40
- print("olefile not available")
41
-
42
- try:
43
- from markdownify import markdownify as md
44
- MARKDOWNIFY_AVAILABLE = True
45
- print("markdownify loaded successfully")
46
- except ImportError:
47
- MARKDOWNIFY_AVAILABLE = False
48
-
49
- try:
50
- import html2text
51
- HTML2TEXT_AVAILABLE = True
52
- print("html2text loaded successfully")
53
- except ImportError:
54
- HTML2TEXT_AVAILABLE = False
55
-
56
- try:
57
- from bs4 import BeautifulSoup
58
- BS4_AVAILABLE = True
59
- except ImportError:
60
- BS4_AVAILABLE = False
61
-
62
- try:
63
- import PyPDF2
64
- PYPDF2_AVAILABLE = True
65
- print("PyPDF2 loaded successfully")
66
- except ImportError:
67
- PYPDF2_AVAILABLE = False
68
 
69
- try:
70
- import pdfplumber
71
- PDFPLUMBER_AVAILABLE = True
72
- print("pdfplumber loaded successfully")
73
- except ImportError:
74
- PDFPLUMBER_AVAILABLE = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
- # hwp5txt 사용 가능 여부 확인
77
- HWP5TXT_AVAILABLE = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  try:
79
- result = subprocess.run(['hwp5txt', '--help'], capture_output=True, timeout=5)
80
- if result.returncode == 0:
81
- HWP5TXT_AVAILABLE = True
82
- print("hwp5txt command available")
83
- except:
84
- pass
85
-
86
- if not HWP5TXT_AVAILABLE:
87
- try:
88
- result = subprocess.run([sys.executable, '-c', 'from hwp5.hwp5txt import main; print("ok")'],
89
- capture_output=True, timeout=5)
90
- if b'ok' in result.stdout:
91
- HWP5TXT_AVAILABLE = True
92
- print("hwp5txt module available")
93
- except:
94
- pass
95
-
96
- print(f"HWP5TXT_AVAILABLE: {HWP5TXT_AVAILABLE}")
97
-
98
- # ============== API 설정 ==============
99
- GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
100
- FIREWORKS_API_KEY = os.environ.get("FIREWORKS_API_KEY", "")
101
-
102
- # ============== SQLite 데이터베이스 ==============
103
- def init_database():
104
- conn = sqlite3.connect(DB_PATH)
105
- cursor = conn.cursor()
106
- cursor.execute('''
107
- CREATE TABLE IF NOT EXISTS sessions (
108
- session_id TEXT PRIMARY KEY,
109
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
110
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
111
- title TEXT
112
- )
113
- ''')
114
- cursor.execute('''
115
- CREATE TABLE IF NOT EXISTS messages (
116
- id INTEGER PRIMARY KEY AUTOINCREMENT,
117
- session_id TEXT,
118
- role TEXT,
119
- content TEXT,
120
- file_info TEXT,
121
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
122
- FOREIGN KEY (session_id) REFERENCES sessions(session_id)
123
- )
124
- ''')
125
- conn.commit()
126
- conn.close()
127
-
128
- def create_session() -> str:
129
- session_id = str(uuid.uuid4())
130
- conn = sqlite3.connect(DB_PATH)
131
- cursor = conn.cursor()
132
- cursor.execute("INSERT INTO sessions (session_id, title) VALUES (?, ?)",
133
- (session_id, f"대화 {datetime.now().strftime('%Y-%m-%d %H:%M')}"))
134
- conn.commit()
135
- conn.close()
136
- return session_id
137
-
138
- def save_message(session_id: str, role: str, content: str, file_info: str = None):
139
- conn = sqlite3.connect(DB_PATH)
140
- cursor = conn.cursor()
141
- cursor.execute("INSERT INTO messages (session_id, role, content, file_info) VALUES (?, ?, ?, ?)",
142
- (session_id, role, content, file_info))
143
- cursor.execute("UPDATE sessions SET updated_at = CURRENT_TIMESTAMP WHERE session_id = ?", (session_id,))
144
- conn.commit()
145
- conn.close()
146
-
147
- def get_session_messages(session_id: str, limit: int = 20) -> List[Dict]:
148
- conn = sqlite3.connect(DB_PATH)
149
- cursor = conn.cursor()
150
- cursor.execute("SELECT role, content, file_info, created_at FROM messages WHERE session_id = ? ORDER BY created_at DESC LIMIT ?",
151
- (session_id, limit))
152
- rows = cursor.fetchall()
153
- conn.close()
154
- return [{"role": r[0], "content": r[1], "file_info": r[2], "created_at": r[3]} for r in reversed(rows)]
155
-
156
- def get_all_sessions() -> List[Dict]:
157
- conn = sqlite3.connect(DB_PATH)
158
- cursor = conn.cursor()
159
- cursor.execute("SELECT session_id, title, created_at, updated_at FROM sessions ORDER BY updated_at DESC LIMIT 50")
160
- rows = cursor.fetchall()
161
- conn.close()
162
- return [{"session_id": r[0], "title": r[1], "created_at": r[2], "updated_at": r[3]} for r in rows]
163
-
164
- def update_session_title(session_id: str, title: str):
165
- conn = sqlite3.connect(DB_PATH)
166
- cursor = conn.cursor()
167
- cursor.execute("UPDATE sessions SET title = ? WHERE session_id = ?", (title, session_id))
168
- conn.commit()
169
- conn.close()
170
-
171
- init_database()
172
-
173
- # ============== 파일 유틸리티 ==============
174
- def extract_text_from_pdf(file_path: str) -> str:
175
- text_parts = []
176
- if PDFPLUMBER_AVAILABLE:
177
- try:
178
- with pdfplumber.open(file_path) as pdf:
179
- for page in pdf.pages:
180
- text = page.extract_text()
181
- if text:
182
- text_parts.append(text)
183
- if text_parts:
184
- return "\n\n".join(text_parts)
185
- except Exception as e:
186
- print(f"pdfplumber error: {e}")
187
-
188
- if PYPDF2_AVAILABLE:
189
- try:
190
- with open(file_path, 'rb') as f:
191
- reader = PyPDF2.PdfReader(f)
192
- for page in reader.pages:
193
- text = page.extract_text()
194
- if text:
195
- text_parts.append(text)
196
- if text_parts:
197
- return "\n\n".join(text_parts)
198
- except Exception as e:
199
- print(f"PyPDF2 error: {e}")
200
- return None
201
-
202
- def extract_text_from_txt(file_path: str) -> str:
203
- for encoding in ['utf-8', 'euc-kr', 'cp949', 'utf-16', 'latin-1']:
204
- try:
205
- with open(file_path, 'r', encoding=encoding) as f:
206
- return f.read()
207
- except:
208
- continue
209
- return None
210
-
211
- def image_to_base64(file_path: str) -> str:
212
- with open(file_path, 'rb') as f:
213
- return base64.b64encode(f.read()).decode('utf-8')
214
-
215
- def get_image_mime_type(file_path: str) -> str:
216
- ext = Path(file_path).suffix.lower()
217
- return {'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png',
218
- '.gif': 'image/gif', '.webp': 'image/webp', '.bmp': 'image/bmp'}.get(ext, 'image/jpeg')
219
-
220
- def is_image_file(fp: str) -> bool:
221
- return Path(fp).suffix.lower() in ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']
222
-
223
- def is_hwp_file(fp: str) -> bool:
224
- return Path(fp).suffix.lower() == '.hwp'
225
-
226
- def is_hwpx_file(fp: str) -> bool:
227
- return Path(fp).suffix.lower() == '.hwpx'
228
-
229
- def is_pdf_file(fp: str) -> bool:
230
- return Path(fp).suffix.lower() == '.pdf'
231
-
232
- def is_text_file(fp: str) -> bool:
233
- return Path(fp).suffix.lower() in ['.txt', '.md', '.json', '.csv', '.xml', '.html', '.css', '.js', '.py']
234
-
235
- # ============== HWPX 텍스트 추출 (ZIP/XML 기반) ==============
236
-
237
- def extract_text_from_hwpx(file_path: str) -> tuple:
238
- """HWPX 파일에서 텍스트 추출 (ZIP 내부 XML 파싱)"""
239
- try:
240
- text_parts = []
241
-
242
- with zipfile.ZipFile(file_path, 'r') as zf:
243
- # HWPX 내부 구조 확인
244
- file_list = zf.namelist()
245
- print(f" HWPX 내부 파일: {file_list[:10]}...")
246
-
247
- # Contents 폴더 내의 section XML 파일들 처리
248
- section_files = sorted([f for f in file_list if f.startswith('Contents/section') and f.endswith('.xml')])
249
-
250
- if not section_files:
251
- # 다른 경로 시도
252
- section_files = sorted([f for f in file_list if 'section' in f.lower() and f.endswith('.xml')])
253
-
254
- print(f" 섹션 파일: {section_files}")
255
-
256
- for section_file in section_files:
257
- try:
258
- with zf.open(section_file) as sf:
259
- content = sf.read()
260
-
261
- # XML 파싱
262
- try:
263
- # 네임스페이스 제거하고 파싱
264
- content_str = content.decode('utf-8')
265
- # 네임스페이스 제거
266
- content_str = re.sub(r'\sxmlns[^"]*"[^"]*"', '', content_str)
267
- content_str = re.sub(r'<[a-zA-Z]+:', '<', content_str)
268
- content_str = re.sub(r'</[a-zA-Z]+:', '</', content_str)
269
-
270
- root = ET.fromstring(content_str)
271
-
272
- # 모든 텍스트 추출
273
- texts = []
274
- for elem in root.iter():
275
- # t 태그 (텍스트)
276
- if elem.tag.endswith('t') or elem.tag == 't':
277
- if elem.text:
278
- texts.append(elem.text)
279
- # 다른 텍스트 노드
280
- elif elem.text and elem.text.strip():
281
- # 태그 이름이 텍스트 관련인 경우
282
- if any(x in elem.tag.lower() for x in ['text', 'run', 'para', 'char']):
283
- texts.append(elem.text.strip())
284
-
285
- if texts:
286
- text_parts.append(' '.join(texts))
287
-
288
- except ET.ParseError as e:
289
- print(f" XML 파싱 오류 {section_file}: {e}")
290
- # 정규식으로 텍스트 추출 시도
291
- text_matches = re.findall(r'>([^<]+)<', content.decode('utf-8', errors='ignore'))
292
- clean_texts = [t.strip() for t in text_matches if t.strip() and len(t.strip()) > 1]
293
- if clean_texts:
294
- text_parts.append(' '.join(clean_texts))
295
-
296
- except Exception as e:
297
- print(f" 섹션 파일 읽기 오류 {section_file}: {e}")
298
- continue
299
-
300
- # header.xml에서도 텍스트 추출 시도
301
- for header_file in [f for f in file_list if 'header' in f.lower() and f.endswith('.xml')]:
302
- try:
303
- with zf.open(header_file) as hf:
304
- content = hf.read().decode('utf-8', errors='ignore')
305
- text_matches = re.findall(r'>([^<]+)<', content)
306
- clean_texts = [t.strip() for t in text_matches if t.strip() and len(t.strip()) > 1]
307
- # 헤더는 짧은 텍스트만 추가
308
- if clean_texts:
309
- text_parts.insert(0, ' '.join(clean_texts[:5]))
310
- except:
311
- pass
312
-
313
- if text_parts:
314
- result = '\n\n'.join(text_parts)
315
- # 정리
316
- result = re.sub(r'\s+', ' ', result)
317
- result = re.sub(r'\n{3,}', '\n\n', result)
318
- return result.strip(), None
319
-
320
- return None, "HWPX에서 텍스트를 찾을 수 없습니다"
321
-
322
- except zipfile.BadZipFile:
323
- return None, "유효하지 않은 HWPX 파일"
324
- except Exception as e:
325
- return None, f"HWPX 처리 오류: {str(e)}"
326
-
327
- # ============== HWP 텍스트 추출 (OLE 기반) ==============
328
-
329
- def extract_text_with_hwp5txt(file_path: str) -> tuple:
330
- """hwp5txt로 텍스트 추출"""
331
-
332
- # 방법 1: hwp5txt 명령어 직접 실행
333
- try:
334
- result = subprocess.run(['hwp5txt', file_path], capture_output=True, timeout=60)
335
- if result.returncode == 0 and result.stdout:
336
- for enc in ['utf-8', 'cp949', 'euc-kr']:
337
- try:
338
- text = result.stdout.decode(enc)
339
- if text.strip() and len(text.strip()) > 10:
340
- return text.strip(), None
341
- except:
342
- continue
343
- except FileNotFoundError:
344
- pass
345
- except Exception as e:
346
- print(f" hwp5txt 명령어 오류: {e}")
347
-
348
- # 방법 2: Python 모듈로 실행
349
- try:
350
- from hwp5.hwp5txt import main as hwp5txt_main
351
- from hwp5.hwp5txt import extract_text
352
- from hwp5.filestructure import Hwp5File
353
-
354
- hwp5file = Hwp5File(file_path)
355
- texts = []
356
-
357
- for section_idx in hwp5file.bodytext.sections():
358
- section = hwp5file.bodytext.section(section_idx)
359
- for para in extract_text(section):
360
- if para.strip():
361
- texts.append(para.strip())
362
-
363
- hwp5file.close()
364
-
365
- if texts:
366
- return '\n'.join(texts), None
367
-
368
- except ImportError:
369
- pass
370
- except Exception as e:
371
- print(f" hwp5txt 모듈 오류: {e}")
372
-
373
- # 방법 3: 서브프로세스로 Python 코드 실행
374
- try:
375
- code = f'''
376
- import sys
377
- sys.path.insert(0, "{PYHWP_PATH}")
378
- from hwp5.filestructure import Hwp5File
379
- from hwp5.hwp5txt import extract_text
380
- hwp = Hwp5File("{file_path}")
381
- for idx in hwp.bodytext.sections():
382
- section = hwp.bodytext.section(idx)
383
- for para in extract_text(section):
384
- if para.strip():
385
- print(para.strip())
386
- hwp.close()
387
- '''
388
- result = subprocess.run([sys.executable, '-c', code], capture_output=True, timeout=60)
389
- if result.returncode == 0 and result.stdout:
390
- for enc in ['utf-8', 'cp949', 'euc-kr']:
391
- try:
392
- text = result.stdout.decode(enc)
393
- if text.strip() and len(text.strip()) > 10:
394
- return text.strip(), None
395
- except:
396
- continue
397
- except Exception as e:
398
- print(f" hwp5txt 서브프로세스 오류: {e}")
399
-
400
- return None, "hwp5txt 실패"
401
-
402
- def extract_text_with_olefile(file_path: str) -> tuple:
403
- """olefile을 사용한 HWP 텍스트 추출"""
404
- if not OLEFILE_AVAILABLE:
405
- return None, "olefile 모듈 없음"
406
-
407
- try:
408
- ole = olefile.OleFileIO(file_path)
409
-
410
- # 파일 헤더 확인
411
- if not ole.exists('FileHeader'):
412
- ole.close()
413
- return None, "HWP 파일 헤더 없음"
414
-
415
- # 압축 여부 확인
416
- header_data = ole.openstream('FileHeader').read()
417
- is_compressed = (header_data[36] & 1) == 1 if len(header_data) > 36 else True
418
- print(f" HWP 압축 여부: {is_compressed}")
419
-
420
- all_texts = []
421
-
422
- # BodyText 섹션들 처리
423
- for entry in ole.listdir():
424
- entry_path = '/'.join(entry)
425
-
426
- if entry_path.startswith('BodyText/Section'):
427
- try:
428
- stream_data = ole.openstream(entry).read()
429
-
430
- # 압축 해제
431
- if is_compressed:
432
- try:
433
- stream_data = zlib.decompress(stream_data, -15)
434
- except:
435
- try:
436
- stream_data = zlib.decompress(stream_data)
437
- except:
438
- pass
439
-
440
- # 레코드에서 텍스트 추출
441
- section_text = extract_hwp_section_text(stream_data)
442
- if section_text:
443
- all_texts.append(section_text)
444
-
445
- except Exception as e:
446
- print(f" 섹션 처리 오류 {entry_path}: {e}")
447
- continue
448
-
449
- ole.close()
450
-
451
- if all_texts:
452
- result = '\n\n'.join(all_texts)
453
- return result.strip(), None
454
-
455
- return None, "텍스트를 찾을 수 없습니다"
456
-
457
- except Exception as e:
458
- return None, f"olefile 오류: {str(e)}"
459
-
460
- def extract_hwp_section_text(data: bytes) -> str:
461
- """HWP 섹션 데이터에서 텍스트 추출"""
462
- texts = []
463
- pos = 0
464
-
465
- while pos < len(data) - 4:
466
- try:
467
- # 레코드 헤더 읽기
468
- header = int.from_bytes(data[pos:pos+4], 'little')
469
- tag_id = header & 0x3FF
470
- level = (header >> 10) & 0x3FF
471
- size = (header >> 20) & 0xFFF
472
-
473
- pos += 4
474
-
475
- # 확장 크기
476
- if size == 0xFFF:
477
- if pos + 4 > len(data):
478
- break
479
- size = int.from_bytes(data[pos:pos+4], 'little')
480
- pos += 4
481
-
482
- if pos + size > len(data):
483
- break
484
-
485
- record_data = data[pos:pos+size]
486
- pos += size
487
-
488
- # HWPTAG_PARA_TEXT = 67
489
- if tag_id == 67 and size > 0:
490
- text = decode_para_text(record_data)
491
- if text:
492
- texts.append(text)
493
-
494
- except:
495
- pos += 1
496
- continue
497
-
498
- return '\n'.join(texts) if texts else None
499
-
500
- def decode_para_text(data: bytes) -> str:
501
- """PARA_TEXT 레코드 디코딩"""
502
- result = []
503
- i = 0
504
-
505
- while i < len(data) - 1:
506
- code = int.from_bytes(data[i:i+2], 'little')
507
-
508
- if code == 0:
509
- pass
510
- elif code == 1: # 확장 컨트롤
511
- i += 14
512
- elif code == 2: # 섹션 정의
513
- i += 14
514
- elif code == 3: # 필드 시작
515
- i += 14
516
- elif code == 4: # 필드 끝
517
- pass
518
- elif code == 9: # 탭
519
- result.append('\t')
520
- elif code == 10: # 줄바꿈
521
- result.append('\n')
522
- elif code == 13: # 문단 끝
523
- result.append('\n')
524
- elif code == 24: # 하이픈
525
- result.append('-')
526
- elif code == 30 or code == 31: # 빈칸
527
- result.append(' ')
528
- elif code < 32: # 기타 컨트롤 문자
529
- pass
530
  else:
531
- # 일반 문자
532
- try:
533
- char = chr(code)
534
- if char.isprintable() or char in '\n\t ':
535
- result.append(char)
536
- except:
537
- pass
538
-
539
- i += 2
540
-
541
- text = ''.join(result).strip()
542
-
543
- # 정리
544
- text = re.sub(r'[ \t]+', ' ', text)
545
- text = re.sub(r'\n{3,}', '\n\n', text)
546
-
547
- return text if len(text) > 2 else None
548
-
549
- def extract_text_from_hwp(file_path: str) -> tuple:
550
- """HWP 파일에서 텍스트 추출 (메인 함수)"""
551
- print(f"\n[HWP 추출] 시작: {os.path.basename(file_path)}")
552
-
553
- # 방법 1: hwp5txt
554
- print(" 방법 1: hwp5txt...")
555
- text, error = extract_text_with_hwp5txt(file_path)
556
- if text and len(text.strip()) > 20:
557
- print(f" ✓ hwp5txt 성공: {len(text)} 글자")
558
- return text, None
559
- print(f" ✗ hwp5txt 실패: {error}")
560
-
561
- # 방법 2: olefile
562
- print(" 방법 2: olefile 파싱...")
563
- text, error = extract_text_with_olefile(file_path)
564
- if text and len(text.strip()) > 20:
565
- print(f" ✓ olefile 성공: {len(text)} 글자")
566
- return text, None
567
- print(f" ✗ olefile 실패: {error}")
568
-
569
- return None, "모든 추출 방법 실패"
570
-
571
- def extract_text_from_hwp_or_hwpx(file_path: str) -> tuple:
572
- """HWP 또는 HWPX 파일에서 텍스트 추출"""
573
- if is_hwpx_file(file_path):
574
- print(f"\n[HWPX 추출] 시작: {os.path.basename(file_path)}")
575
- return extract_text_from_hwpx(file_path)
576
  else:
577
- return extract_text_from_hwp(file_path)
578
-
579
- # ============== HWP 변환 함수들 ==============
580
-
581
- def check_hwp_version(file_path):
582
- try:
583
- with open(file_path, 'rb') as f:
584
- header = f.read(32)
585
- if b'HWP Document File' in header:
586
- return "HWP v5", True
587
- elif header[:4] == b'\xd0\xcf\x11\xe0':
588
- return "HWP v5 (OLE)", True
589
- elif header[:4] == b'PK\x03\x04': # ZIP 파일 (HWPX)
590
- return "HWPX", True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  else:
592
- return "Unknown", False
593
- except Exception as e:
594
- return f"Error: {e}", False
595
 
596
- def convert_to_html_subprocess(input_path, output_dir):
597
- """HTML 변환"""
598
- output_path = os.path.join(output_dir, "output.html")
599
-
600
- try:
601
- # hwp5html 시도
602
- for cmd in [['hwp5html', '--output', output_path, input_path],
603
- [sys.executable, '-c', f'from hwp5.hwp5html import main; import sys; sys.argv=["hwp5html","--output","{output_path}","{input_path}"]; main()']]:
604
  try:
605
- result = subprocess.run(cmd, capture_output=True, timeout=120)
606
- if result.returncode == 0:
607
- if os.path.exists(output_path):
608
- return output_path, None
609
- # 디렉토리 검색
610
- for item in os.listdir(output_dir):
611
- item_path = os.path.join(output_dir, item)
612
- if item.lower().endswith(('.html', '.htm')):
613
- return item_path, None
614
- if os.path.isdir(item_path):
615
- return item_path, None
616
- except:
617
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
618
 
619
- except Exception as e:
620
- print(f"HTML 변환 오류: {e}")
621
-
622
- return None, "HTML 변환 실패"
623
-
624
- def convert_hwp_to_text(input_path: str) -> tuple:
625
- """HWP/HWPX를 텍스트로 변환"""
626
- return extract_text_from_hwp_or_hwpx(input_path)
627
-
628
- def html_to_markdown(html_content):
629
- """HTML을 Markdown으로 변환"""
630
- if MARKDOWNIFY_AVAILABLE:
631
- try:
632
- return md(html_content, heading_style="ATX", bullets="-"), None
633
- except:
634
- pass
635
-
636
- if HTML2TEXT_AVAILABLE:
637
- try:
638
- h = html2text.HTML2Text()
639
- h.body_width = 0
640
- return h.handle(html_content), None
641
- except:
642
- pass
643
-
644
- if BS4_AVAILABLE:
 
 
 
645
  try:
646
- soup = BeautifulSoup(html_content, 'html.parser')
647
- return soup.get_text(separator='\n'), None
648
- except:
649
- pass
650
-
651
- return None, "Markdown 변환 실패"
652
-
653
- def convert_hwp_to_markdown(input_path: str) -> tuple:
654
- """HWP/HWPX를 Markdown으로 변환"""
655
- # 텍스트 추출
656
- text, error = extract_text_from_hwp_or_hwpx(input_path)
657
- if text:
658
- return text, None
659
- return None, error
660
-
661
- # ============== LLM API ==============
662
-
663
- def call_groq_api_stream(messages: List[Dict], api_key: str) -> Generator[str, None, None]:
664
- if not api_key:
665
- yield "❌ Groq API 키가 설정되지 않았습니다."
666
- return
667
-
668
- try:
669
- response = requests.post(
670
- "https://api.groq.com/openai/v1/chat/completions",
671
- headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
672
- json={
673
- "model": "meta-llama/llama-4-scout-17b-16e-instruct",
674
- "messages": messages,
675
- "temperature": 0.7,
676
- "max_tokens": 8192,
677
- "stream": True
678
- },
679
- stream=True
680
- )
681
-
682
- if response.status_code != 200:
683
- yield f"❌ Groq API 오류: {response.status_code}"
684
- return
685
-
686
- for line in response.iter_lines():
687
- if line:
688
- line = line.decode('utf-8')
689
- if line.startswith('data: ') and line[6:] != '[DONE]':
690
- try:
691
- data = json.loads(line[6:])
692
- content = data.get('choices', [{}])[0].get('delta', {}).get('content', '')
693
- if content:
694
- yield content
695
- except:
696
- continue
697
- except Exception as e:
698
- yield f"❌ API 오류: {str(e)}"
699
-
700
- def call_fireworks_api_stream(messages: List[Dict], image_base64: str, mime_type: str, api_key: str) -> Generator[str, None, None]:
701
- if not api_key:
702
- yield "❌ Fireworks API 키가 설정되지 않았습니다."
703
- return
704
-
705
- try:
706
- formatted_messages = [{"role": m["role"], "content": m["content"]} for m in messages[:-1]]
707
- formatted_messages.append({
708
- "role": messages[-1]["role"],
709
- "content": [
710
- {"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{image_base64}"}},
711
- {"type": "text", "text": messages[-1]["content"]}
712
- ]
713
- })
714
-
715
- response = requests.post(
716
- "https://api.fireworks.ai/inference/v1/chat/completions",
717
- headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
718
- json={
719
- "model": "accounts/fireworks/models/qwen3-vl-235b-a22b-thinking",
720
- "max_tokens": 4096,
721
- "temperature": 0.6,
722
- "messages": formatted_messages,
723
- "stream": True
724
- },
725
- stream=True
726
- )
727
-
728
- if response.status_code != 200:
729
- yield f"❌ Fireworks API 오류: {response.status_code}"
730
- return
731
-
732
- for line in response.iter_lines():
733
- if line:
734
- line = line.decode('utf-8')
735
- if line.startswith('data: ') and line[6:] != '[DONE]':
736
- try:
737
- data = json.loads(line[6:])
738
- content = data.get('choices', [{}])[0].get('delta', {}).get('content', '')
739
- if content:
740
- yield content
741
- except:
742
- continue
743
- except Exception as e:
744
- yield f"❌ API 오류: {str(e)}"
745
-
746
- # ============== 채팅 처리 ==============
747
-
748
- def process_file(file_path: str) -> tuple:
749
- if not file_path:
750
- return None, None, None
751
-
752
- filename = os.path.basename(file_path)
753
-
754
- if is_image_file(file_path):
755
- return "image", image_to_base64(file_path), get_image_mime_type(file_path)
756
-
757
- if is_hwp_file(file_path) or is_hwpx_file(file_path):
758
- text, error = extract_text_from_hwp_or_hwpx(file_path)
759
- if text and len(text.strip()) > 20:
760
- return "text", f"[한글 문서: {filename}]\n\n{text}", None
761
- return "error", f"한글 문서 추출 실패: {error}", None
762
-
763
- if is_pdf_file(file_path):
764
- text = extract_text_from_pdf(file_path)
765
- if text:
766
- return "text", f"[PDF 문서: {filename}]\n\n{text}", None
767
- return "error", "PDF 추출 실패", None
768
-
769
- if is_text_file(file_path):
770
- text = extract_text_from_txt(file_path)
771
- if text:
772
- return "text", f"[텍스트 파일: {filename}]\n\n{text}", None
773
- return "error", "텍스트 읽기 실패", None
774
-
775
- return "unsupported", f"지원하지 않는 형식: {filename}", None
776
-
777
- def chat_response(message: str, history: List[Dict], file: Optional[str],
778
- session_id: str, groq_key: str, fireworks_key: str) -> Generator[tuple, None, None]:
779
- if history is None:
780
- history = []
781
-
782
- if not message.strip() and not file:
783
- yield history, session_id
784
- return
785
-
786
- if not session_id:
787
- session_id = create_session()
788
-
789
- # 파일 처리
790
- file_type, file_content, file_mime = None, None, None
791
- file_info = None
792
-
793
- if file:
794
- file_type, file_content, file_mime = process_file(file)
795
- file_info = json.dumps({"type": file_type, "filename": os.path.basename(file)})
796
-
797
- if file_type == "error":
798
- history = history + [
799
- {"role": "user", "content": message or "파일 업로드"},
800
- {"role": "assistant", "content": f"❌ {file_content}"}
801
- ]
802
- yield history, session_id
803
- return
804
- elif file_type == "unsupported":
805
- history = history + [
806
- {"role": "user", "content": message or "파일 업로드"},
807
- {"role": "assistant", "content": f"⚠️ {file_content}"}
808
- ]
809
- yield history, session_id
810
- return
811
-
812
- # 사용자 메시지
813
- user_msg = message
814
- if file:
815
- filename = os.path.basename(file)
816
- user_msg = f"📎 {filename}\n\n{message}" if message else f"📎 {filename}"
817
-
818
- history = history + [{"role": "user", "content": user_msg}, {"role": "assistant", "content": ""}]
819
- yield history, session_id
820
-
821
- # API 메시지 구성
822
- db_messages = get_session_messages(session_id, limit=10)
823
- api_messages = [{
824
- "role": "system",
825
- "content": "당신은 도움이 되는 AI 어시스턴트입니다. 한국어로 자연스럽게 대화하며, 파일이 첨부되면 내용을 상세히 분석하여 답변합니다."
826
- }]
827
-
828
- for m in db_messages:
829
- api_messages.append({"role": m["role"], "content": m["content"]})
830
-
831
- current_content = message or ""
832
- if file_type == "text" and file_content:
833
- current_content = f"{file_content}\n\n사용자 질문: {message}" if message else f"{file_content}\n\n위 문서 내용을 요약해주세요."
834
-
835
- api_messages.append({"role": "user", "content": current_content})
836
-
837
- # 응답 생성
838
- full_response = ""
839
- if file_type == "image":
840
- for chunk in call_fireworks_api_stream(api_messages, file_content, file_mime, fireworks_key):
841
- full_response += chunk
842
- history[-1] = {"role": "assistant", "content": full_response}
843
- yield history, session_id
844
  else:
845
- for chunk in call_groq_api_stream(api_messages, groq_key):
846
- full_response += chunk
847
- history[-1] = {"role": "assistant", "content": full_response}
848
- yield history, session_id
849
-
850
- # 저장
851
- save_message(session_id, "user", current_content, file_info)
852
- save_message(session_id, "assistant", full_response)
853
-
854
- if len(db_messages) == 0 and message:
855
- update_session_title(session_id, message[:50])
856
-
857
- def new_chat():
858
- return [], create_session(), None
859
-
860
- def load_session(session_id: str) -> tuple:
861
- if not session_id:
862
- return [], ""
863
- messages = get_session_messages(session_id, limit=50)
864
- return [{"role": m["role"], "content": m["content"]} for m in messages], session_id
865
-
866
- # ============== HWP 변환기 (Tab 2) ==============
867
-
868
- def convert_to_odt_subprocess(input_path, output_dir):
869
- output_path = os.path.join(output_dir, "output.odt")
870
- try:
871
- for cmd in [['hwp5odt', '--output', output_path, input_path],
872
- [sys.executable, '-c', f'from hwp5.hwp5odt import main; import sys; sys.argv=["hwp5odt","--output","{output_path}","{input_path}"]; main()']]:
873
- try:
874
- result = subprocess.run(cmd, capture_output=True, timeout=120)
875
- if result.returncode == 0 and os.path.exists(output_path):
876
- return output_path, None
877
- except:
878
- continue
879
- except:
880
- pass
881
- return None, "ODT 변환 실패"
882
-
883
- def convert_to_xml_subprocess(input_path, output_dir):
884
- output_path = os.path.join(output_dir, "output.xml")
885
- try:
886
- for cmd in [['hwp5xml', input_path],
887
- [sys.executable, '-c', f'from hwp5.hwp5xml import main; import sys; sys.argv=["hwp5xml","{input_path}"]; main()']]:
888
- try:
889
- result = subprocess.run(cmd, capture_output=True, timeout=120)
890
- if result.returncode == 0 and result.stdout:
891
- with open(output_path, 'wb') as f:
892
- f.write(result.stdout)
893
- return output_path, None
894
- except:
895
- continue
896
- except:
897
- pass
898
- return None, "XML 변환 실패"
899
-
900
- def convert_hwp(file, output_format, progress=gr.Progress()):
901
- if not file:
902
- return None, "❌ 파일을 업로드해주세요.", ""
903
-
904
- input_file = file.name if hasattr(file, 'name') else str(file)
905
- ext_lower = Path(input_file).suffix.lower()
906
-
907
- if ext_lower not in ['.hwp', '.hwpx']:
908
- return None, " HWP 또는 HWPX 파일만 지원됩니다.", ""
909
-
910
- progress(0.1, desc="파일 분석 중...")
911
- version, is_valid = check_hwp_version(input_file)
912
- if not is_valid:
913
- return None, f" 지원하지 않는 파일: {version}", ""
914
-
915
- tmp_dir = tempfile.mkdtemp()
916
-
917
- try:
918
- input_filename = os.path.basename(input_file)
919
- input_path = os.path.join(tmp_dir, input_filename)
920
- shutil.copy(input_file, input_path)
921
-
922
- progress(0.3, desc=f"{output_format}로 변환 중...")
923
-
924
- output_path, error, ext = None, None, ""
925
-
926
- if output_format == "HTML":
927
- if ext_lower == '.hwpx':
928
- return None, "❌ HWPX는 HTML 변환을 지원하지 않습니다. TXT나 Markdown을 사용하세요.", ""
929
- output_path, error = convert_to_html_subprocess(input_path, tmp_dir)
930
- ext = ".html"
931
- if output_path and os.path.isdir(output_path):
932
- zip_path = shutil.make_archive(os.path.join(tmp_dir, "html"), 'zip', output_path)
933
- output_path, ext = zip_path, ".zip"
934
-
935
- elif output_format == "ODT (OpenDocument)":
936
- if ext_lower == '.hwpx':
937
- return None, "❌ HWPX는 ODT 변환을 지원하지 않습니다. TXT나 Markdown을 사용하세요.", ""
938
- output_path, error = convert_to_odt_subprocess(input_path, tmp_dir)
939
- ext = ".odt"
940
-
941
- elif output_format == "TXT (텍스트)":
942
- text, error = extract_text_from_hwp_or_hwpx(input_path)
943
- if text:
944
- output_path = os.path.join(tmp_dir, "output.txt")
945
- with open(output_path, 'w', encoding='utf-8') as f:
946
- f.write(text)
947
- ext = ".txt"
948
 
949
- elif output_format == "Markdown":
950
- text, error = convert_hwp_to_markdown(input_path)
951
- if text:
952
- output_path = os.path.join(tmp_dir, "output.md")
953
- with open(output_path, 'w', encoding='utf-8') as f:
954
- f.write(text)
955
- ext = ".md"
956
 
957
- elif output_format == "XML":
958
- if ext_lower == '.hwpx':
959
- # HWPX는 이미 XML 기반이므로 내부 XML 추출
960
- try:
961
- with zipfile.ZipFile(input_path, 'r') as zf:
962
- # 모든 XML 파일을 하나로 합침
963
- xml_contents = []
964
- for name in zf.namelist():
965
- if name.endswith('.xml'):
966
- with zf.open(name) as f:
967
- xml_contents.append(f"<!-- {name} -->\n{f.read().decode('utf-8', errors='ignore')}")
968
-
969
- output_path = os.path.join(tmp_dir, "output.xml")
970
- with open(output_path, 'w', encoding='utf-8') as f:
971
- f.write('\n\n'.join(xml_contents))
972
- except Exception as e:
973
- error = f"HWPX XML 추출 실패: {e}"
974
- else:
975
- output_path, error = convert_to_xml_subprocess(input_path, tmp_dir)
976
- ext = ".xml"
977
-
978
- if not output_path:
979
- return None, f"❌ {error or '변환 실패'}", ""
980
-
981
- if not os.path.exists(output_path):
982
- return None, "❌ 변환된 파일을 찾을 수 없습니다.", ""
983
-
984
- progress(0.8, desc="완료...")
985
-
986
- base_name = Path(input_filename).stem
987
- final_output = os.path.join(tmp_dir, f"{base_name}{ext}")
988
- if output_path != final_output:
989
- shutil.copy2(output_path, final_output)
990
-
991
- file_size = os.path.getsize(final_output)
992
- size_str = f"{file_size/1024:.1f} KB" if file_size > 1024 else f"{file_size} bytes"
993
-
994
- preview = ""
995
- if ext in ['.txt', '.md', '.xml']:
996
- try:
997
- with open(final_output, 'r', encoding='utf-8', errors='ignore') as f:
998
- preview = f.read(5000)
999
- if len(preview) >= 5000:
1000
- preview += "\n\n... (생략)"
1001
- except:
1002
- pass
1003
- elif ext == '.zip':
1004
- preview = "📦 HTML이 ZIP으로 압���되었습니다."
1005
-
1006
- progress(1.0, desc="완료!")
1007
- return final_output, f"✅ 변환 완료: {base_name}{ext} ({size_str})", preview
1008
-
1009
- except Exception as e:
1010
- import traceback
1011
- traceback.print_exc()
1012
- return None, f"❌ 오류: {str(e)}", ""
1013
-
1014
- # ============== Gradio UI ==============
1015
-
1016
- css = """
1017
- .upload-box { border: 2px dashed #6366f1 !important; border-radius: 12px !important; }
1018
- .download-box { border: 2px solid #22c55e !important; border-radius: 12px !important; }
1019
- """
1020
-
1021
- with gr.Blocks(title="AI 문서 어시스턴트") as demo:
1022
- session_state = gr.State("")
1023
-
1024
- gr.Markdown("# 🤖 AI 문서 어시스턴트\nLLM 채팅 + HWP/HWPX 문서 변환")
1025
-
1026
- with gr.Tabs():
1027
- with gr.Tab("💬 AI 채팅"):
1028
- with gr.Row():
1029
- with gr.Column(scale=1):
1030
- gr.Markdown("### ⚙️ 설정")
1031
- with gr.Accordion("🔑 API 키", open=True):
1032
- groq_key = gr.Textbox(label="Groq API Key", type="password", value=GROQ_API_KEY)
1033
- fireworks_key = gr.Textbox(label="Fireworks API Key", type="password", value=FIREWORKS_API_KEY)
1034
-
1035
- gr.Markdown("### 📁 지원 파일\n- 이미지: JPG, PNG\n- 문서: PDF, TXT\n- 한글: HWP, HWPX ✨")
1036
- new_btn = gr.Button("🆕 새 대화", variant="primary")
1037
-
1038
- with gr.Accordion("📜 기록", open=False):
1039
- session_list = gr.Dataframe(headers=["ID", "제목", "시간"], interactive=False)
1040
- refresh_btn = gr.Button("🔄 새로고침", size="sm")
1041
-
1042
- with gr.Column(scale=3):
1043
- chatbot = gr.Chatbot(label="대화", height=500)
1044
-
1045
- with gr.Row():
1046
- file_upload = gr.File(label="📎 파일", file_types=[".jpg",".jpeg",".png",".gif",".webp",".pdf",".txt",".md",".hwp",".hwpx"], scale=1)
1047
- msg_input = gr.Textbox(placeholder="메시지 입력...", lines=2, show_label=False, scale=4)
1048
-
1049
- with gr.Row():
1050
- submit_btn = gr.Button("📤 전송", variant="primary", scale=3)
1051
- clear_btn = gr.Button("🗑️ 지우기", scale=1)
1052
-
1053
- with gr.Tab("📄 HWP 변환기"):
1054
- gr.Markdown("### HWP/HWPX 파일 변환기")
1055
  with gr.Row():
1056
- with gr.Column():
1057
- hwp_input = gr.File(label="HWP/HWPX 파일", file_types=[".hwp", ".hwpx"], elem_classes=["upload-box"])
1058
- format_select = gr.Radio(["HTML", "ODT (OpenDocument)", "TXT (텍스트)", "Markdown", "XML"], value="TXT (텍스트)", label="형식")
1059
- convert_btn = gr.Button("🔄 변환", variant="primary", size="lg")
1060
- with gr.Column():
1061
- status_out = gr.Textbox(label="상태", interactive=False)
1062
- file_out = gr.File(label="다운로드", elem_classes=["download-box"])
1063
 
1064
- with gr.Accordion("📋 미리보기", open=False):
1065
- preview_out = gr.Textbox(lines=15, interactive=False)
 
1066
 
1067
- gr.Markdown("""
1068
- > **참고**: HWPX 파일은 TXT, Markdown, XML 변환만 지원됩니다.
1069
- """)
1070
-
1071
- # 이벤트
1072
- def on_submit(msg, hist, f, sid, gk, fk):
1073
- if hist is None: hist = []
1074
- for r in chat_response(msg, hist, f, sid, gk, fk):
1075
- yield r[0], r[1], "", None
1076
-
1077
- submit_btn.click(on_submit, [msg_input, chatbot, file_upload, session_state, groq_key, fireworks_key],
1078
- [chatbot, session_state, msg_input, file_upload])
1079
- msg_input.submit(on_submit, [msg_input, chatbot, file_upload, session_state, groq_key, fireworks_key],
1080
- [chatbot, session_state, msg_input, file_upload])
1081
-
1082
- new_btn.click(lambda: ([], create_session(), None, ""), outputs=[chatbot, session_state, file_upload, msg_input])
1083
- clear_btn.click(lambda: ([], None, ""), outputs=[chatbot, file_upload, msg_input])
1084
-
1085
- def refresh():
1086
- sessions = get_all_sessions()
1087
- return [[s["session_id"][:8], s["title"] or "제목없음", s["updated_at"][:16] if s["updated_at"] else ""] for s in sessions]
1088
-
1089
- refresh_btn.click(refresh, outputs=[session_list])
1090
-
1091
- def select_session(evt: gr.SelectData, data):
1092
- if evt.index[0] < len(data):
1093
- for s in get_all_sessions():
1094
- if s["session_id"].startswith(data[evt.index[0]][0]):
1095
- return load_session(s["session_id"])
1096
- return [], ""
1097
-
1098
- session_list.select(select_session, [session_list], [chatbot, session_state])
1099
- convert_btn.click(convert_hwp, [hwp_input, format_select], [file_out, status_out, preview_out])
1100
- demo.load(refresh, outputs=[session_list])
1101
-
1102
- if __name__ == "__main__":
1103
- demo.launch(css=css)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
 
 
 
 
2
  import json
3
+ import copy
4
+ import time
 
5
  import requests
6
+ import random
7
+ import logging
8
+ import numpy as np
9
+ import spaces
10
+ from typing import Any, Dict, List, Optional, Union
11
+
12
+ import torch
13
+ from PIL import Image
14
+ import gradio as gr
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
+ from diffusers import (
17
+ DiffusionPipeline,
18
+ AutoencoderKL,
19
+ ZImagePipeline
20
+ )
21
+
22
+ from huggingface_hub import (
23
+ hf_hub_download,
24
+ HfFileSystem,
25
+ ModelCard,
26
+ snapshot_download)
27
+
28
+ from diffusers.utils import load_image
29
+ from typing import Iterable
30
+
31
+ # ============================================
32
+ # Comic Style CSS
33
+ # ============================================
34
+ COMIC_CSS = """
35
+ @import url('https://fonts.googleapis.com/css2?family=Bangers&family=Comic+Neue:wght@400;700&display=swap');
36
+
37
+ .gradio-container {
38
+ background-color: #FEF9C3 !important;
39
+ background-image: radial-gradient(#1F2937 1px, transparent 1px) !important;
40
+ background-size: 20px 20px !important;
41
+ min-height: 100vh !important;
42
+ font-family: 'Comic Neue', cursive, sans-serif !important;
43
+ }
44
+
45
+ footer, .footer, .gradio-container footer, .built-with, [class*="footer"], .gradio-footer, a[href*="gradio.app"] {
46
+ display: none !important;
47
+ visibility: hidden !important;
48
+ height: 0 !important;
49
+ }
50
+
51
+ /* HOME Button Style */
52
+ .home-button-container {
53
+ display: flex;
54
+ justify-content: center;
55
+ align-items: center;
56
+ gap: 15px;
57
+ margin-bottom: 15px;
58
+ padding: 12px 20px;
59
+ background: linear-gradient(135deg, #10B981 0%, #059669 100%);
60
+ border: 4px solid #1F2937;
61
+ border-radius: 12px;
62
+ box-shadow: 6px 6px 0 #1F2937;
63
+ }
64
+
65
+ .home-button {
66
+ display: inline-flex;
67
+ align-items: center;
68
+ gap: 8px;
69
+ padding: 10px 25px;
70
+ background: linear-gradient(135deg, #FACC15 0%, #F59E0B 100%);
71
+ color: #1F2937;
72
+ font-family: 'Bangers', cursive;
73
+ font-size: 1.4rem;
74
+ letter-spacing: 2px;
75
+ text-decoration: none;
76
+ border: 3px solid #1F2937;
77
+ border-radius: 8px;
78
+ box-shadow: 4px 4px 0 #1F2937;
79
+ transition: all 0.2s ease;
80
+ }
81
+
82
+ .home-button:hover {
83
+ background: linear-gradient(135deg, #FDE047 0%, #FACC15 100%);
84
+ transform: translate(-2px, -2px);
85
+ box-shadow: 6px 6px 0 #1F2937;
86
+ }
87
+
88
+ .home-button:active {
89
+ transform: translate(2px, 2px);
90
+ box-shadow: 2px 2px 0 #1F2937;
91
+ }
92
+
93
+ .url-display {
94
+ font-family: 'Comic Neue', cursive;
95
+ font-size: 1.1rem;
96
+ font-weight: 700;
97
+ color: #FFF;
98
+ background: rgba(0,0,0,0.3);
99
+ padding: 8px 16px;
100
+ border-radius: 6px;
101
+ border: 2px solid rgba(255,255,255,0.3);
102
+ }
103
+
104
+ .header-container {
105
+ text-align: center;
106
+ padding: 25px 20px;
107
+ background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%);
108
+ border: 4px solid #1F2937;
109
+ border-radius: 12px;
110
+ margin-bottom: 20px;
111
+ box-shadow: 8px 8px 0 #1F2937;
112
+ position: relative;
113
+ }
114
+
115
+ .header-title {
116
+ font-family: 'Bangers', cursive !important;
117
+ color: #FFF !important;
118
+ font-size: 2.8rem !important;
119
+ text-shadow: 3px 3px 0 #1F2937 !important;
120
+ letter-spacing: 3px !important;
121
+ margin: 0 !important;
122
+ }
123
+
124
+ .header-subtitle {
125
+ font-family: 'Comic Neue', cursive !important;
126
+ font-size: 1.1rem !important;
127
+ color: #FEF9C3 !important;
128
+ margin-top: 8px !important;
129
+ font-weight: 700 !important;
130
+ }
131
+
132
+ .stats-badge {
133
+ display: inline-block;
134
+ background: #FACC15;
135
+ color: #1F2937;
136
+ padding: 6px 14px;
137
+ border-radius: 20px;
138
+ font-size: 0.9rem;
139
+ margin: 3px;
140
+ font-weight: 700;
141
+ border: 2px solid #1F2937;
142
+ box-shadow: 2px 2px 0 #1F2937;
143
+ }
144
+
145
+ .gr-panel, .gr-box, .gr-form, .block, .gr-group {
146
+ background: #FFF !important;
147
+ border: 3px solid #1F2937 !important;
148
+ border-radius: 8px !important;
149
+ box-shadow: 5px 5px 0 #1F2937 !important;
150
+ }
151
+
152
+ .gr-button-primary, button.primary, .gr-button.primary {
153
+ background: linear-gradient(135deg, #EF4444 0%, #F97316 100%) !important;
154
+ border: 3px solid #1F2937 !important;
155
+ border-radius: 8px !important;
156
+ color: #FFF !important;
157
+ font-family: 'Bangers', cursive !important;
158
+ font-size: 1.3rem !important;
159
+ letter-spacing: 2px !important;
160
+ padding: 12px 24px !important;
161
+ box-shadow: 4px 4px 0 #1F2937 !important;
162
+ text-shadow: 1px 1px 0 #1F2937 !important;
163
+ transition: all 0.2s ease !important;
164
+ }
165
+
166
+ .gr-button-primary:hover, button.primary:hover {
167
+ background: linear-gradient(135deg, #DC2626 0%, #EA580C 100%) !important;
168
+ transform: translate(-2px, -2px) !important;
169
+ box-shadow: 6px 6px 0 #1F2937 !important;
170
+ }
171
+
172
+ .gr-button-primary:active, button.primary:active {
173
+ transform: translate(2px, 2px) !important;
174
+ box-shadow: 2px 2px 0 #1F2937 !important;
175
+ }
176
+
177
+ textarea, input[type="text"], input[type="number"] {
178
+ background: #FFF !important;
179
+ border: 3px solid #1F2937 !important;
180
+ border-radius: 8px !important;
181
+ color: #1F2937 !important;
182
+ font-family: 'Comic Neue', cursive !important;
183
+ font-weight: 700 !important;
184
+ }
185
+
186
+ textarea:focus, input[type="text"]:focus {
187
+ border-color: #3B82F6 !important;
188
+ box-shadow: 3px 3px 0 #3B82F6 !important;
189
+ }
190
+
191
+ .info-box {
192
+ background: linear-gradient(135deg, #FACC15 0%, #FDE047 100%) !important;
193
+ border: 3px solid #1F2937 !important;
194
+ border-radius: 8px !important;
195
+ padding: 12px 15px !important;
196
+ margin: 10px 0 !important;
197
+ box-shadow: 4px 4px 0 #1F2937 !important;
198
+ font-family: 'Comic Neue', cursive !important;
199
+ font-weight: 700 !important;
200
+ color: #1F2937 !important;
201
+ }
202
+
203
+ label, .gr-input-label, .gr-block-label {
204
+ color: #1F2937 !important;
205
+ font-family: 'Comic Neue', cursive !important;
206
+ font-weight: 700 !important;
207
+ }
208
+
209
+ .gr-accordion {
210
+ background: #E0F2FE !important;
211
+ border: 3px solid #1F2937 !important;
212
+ border-radius: 8px !important;
213
+ box-shadow: 4px 4px 0 #1F2937 !important;
214
+ }
215
+
216
+ .footer-comic {
217
+ text-align: center;
218
+ padding: 20px;
219
+ background: linear-gradient(135deg, #3B82F6 0%, #8B5CF6 100%);
220
+ border: 4px solid #1F2937;
221
+ border-radius: 12px;
222
+ margin-top: 20px;
223
+ box-shadow: 6px 6px 0 #1F2937;
224
+ }
225
+
226
+ .footer-comic p {
227
+ font-family: 'Comic Neue', cursive !important;
228
+ color: #FFF !important;
229
+ margin: 5px 0 !important;
230
+ font-weight: 700 !important;
231
+ }
232
+
233
+ ::-webkit-scrollbar {
234
+ width: 12px;
235
+ height: 12px;
236
+ }
237
+
238
+ ::-webkit-scrollbar-track {
239
+ background: #FEF9C3;
240
+ border: 2px solid #1F2937;
241
+ }
242
+
243
+ ::-webkit-scrollbar-thumb {
244
+ background: #3B82F6;
245
+ border: 2px solid #1F2937;
246
+ border-radius: 6px;
247
+ }
248
+
249
+ ::-webkit-scrollbar-thumb:hover {
250
+ background: #EF4444;
251
+ }
252
+
253
+ ::selection {
254
+ background: #FACC15;
255
+ color: #1F2937;
256
+ }
257
+
258
+ /* Slider Styling */
259
+ input[type="range"] {
260
+ accent-color: #3B82F6;
261
+ }
262
+
263
+ /* Image/Gallery Container */
264
+ .gr-image, .gr-gallery {
265
+ border: 3px solid #1F2937 !important;
266
+ border-radius: 8px !important;
267
+ box-shadow: 4px 4px 0 #1F2937 !important;
268
+ }
269
+
270
+ /* Original CSS additions */
271
+ #gen_btn{height: 100%}
272
+ #gen_column{align-self: stretch}
273
+ #title{text-align: center}
274
+ #title h1{font-size: 3em; display:inline-flex; align-items:center}
275
+ #title img{width: 100px; margin-right: 0.5em}
276
+ #gallery .grid-wrap{height: 10vh}
277
+ #lora_list{background: var(--block-background-fill);padding: 0 1em .3em; font-size: 90%}
278
+ .card_internal{display: flex;height: 100px;margin-top: .5em}
279
+ .card_internal img{margin-right: 1em}
280
+ .styler{--form-gap-width: 0px !important}
281
+ #progress{height:30px}
282
+ #progress .generating{display:none}
283
+ .progress-container {width: 100%;height: 30px;background-color: #f0f0f0;border-radius: 15px;overflow: hidden;margin-bottom: 20px}
284
+ .progress-bar {height: 100%;background-color: #4f46e5;width: calc(var(--current) / var(--total) * 100%);transition: width 0.5s ease-in-out}
285
+ """
286
 
287
+ loras = [
288
+ # 로컬 jimin LoRA (app.py와 같은 디렉토리에 jimin.safetensors 필요)
289
+ {
290
+ "image": "https://i.namu.wiki/i/VxF2jh087d4V6f9LWw1jQoLcQ_ymdhyD7XtRnq2KVYmN6DZsL4vCTrd1v-ubr8zfejyCCKvUWaBVf9JM9GqR271X6nh4e6mUbW11LaFr9QtepztFJeDZJ1VISkW5KBbebCpqv-w2Uv7RmMPwB5kacg.webp",
291
+
292
+ "title": "koyoon Style",
293
+ "repo": "./", # 로컬 경로
294
+ "weights": "koyoon.safetensors",
295
+ "trigger_word": "koyoon"
296
+ },
297
+
298
+ {
299
+ "image": "https://i.namu.wiki/i/umL8EZtn0hs-nMRYeFxIrkGrMe-R1u5c9fJE8ufrLjvXz52VcSIbG7TT9QJoL2rR7vsFww1lLrE4bwfn5uOBzfq9a90HGdNdlTLmr_KoqOchTovbVC3RDzhDbp7FI-Wq-esCu7_BYIptqethL4onBg.webp",
300
+
301
+ "title": "jimin Style",
302
+ "repo": "./", # 로컬 경로
303
+ "weights": "jimin.safetensors",
304
+ "trigger_word": "jimin"
305
+ },
306
+ {
307
+ "image": "https://huggingface.co/strangerzonehf/Flux-Ultimate-LoRA-Collection/resolve/main/images/1111111111.png",
308
+ "title": "AWPortrait Z",
309
+ "repo": "Shakker-Labs/AWPortrait-Z", #1
310
+ "weights": "AWPortrait-Z.safetensors",
311
+ "trigger_word": "Portrait"
312
+ },
313
+
314
+ {
315
+ "image": "https://cdn-uploads.huggingface.co/production/uploads/653cd3049107029eb004f968/DLCGlF9uUnFo5zxR5uyx6.png",
316
+ "title": "50s Western",
317
+ "repo": "neph1/50s_western_lora_zit",
318
+ "weights": "50s_western_z_100.safetensors",
319
+ "trigger_word": "50s_western"
320
+ },
321
+
322
+ {
323
+ "image": "https://huggingface.co/neph1/80s_scifi_lora_zit/resolve/main/images/ComfyUI_10288_.png",
324
+ "title": "80s Scifi",
325
+ "repo": "neph1/80s_scifi_lora_zit",
326
+ "weights": "80s_scifi_z_80.safetensors",
327
+ "trigger_word": "80s_scifi"
328
+ },
329
+
330
+ # --------------------------------------------------------------------------------------------------------------------------------------
331
+ {
332
+ "image": "https://huggingface.co/Ttio2/Z-Image-Turbo-pencil-sketch/resolve/main/images/z-image_00097_.png",
333
+ "title": "Turbo Pencil",
334
+ "repo": "Ttio2/Z-Image-Turbo-pencil-sketch", #0
335
+ "weights": "Zimage_pencil_sketch.safetensors",
336
+ "trigger_word": "pencil sketch"
337
+ },
338
+ {
339
+ "image": "https://huggingface.co/neph1/50s_scifi_lora_zit/resolve/main/images/ComfyUI_08067_.png",
340
+ "title": "50s Scifi",
341
+ "repo": "neph1/50s_scifi_lora_zit",
342
+ "weights": "50s_scifi_z_80.safetensors",
343
+ "trigger_word": "50s_scifi"
344
+ },
345
+ {
346
+ "image": "https://huggingface.co/strangerzonehf/Flux-Ultimate-LoRA-Collection/resolve/main/images/cookie-mons.png",
347
+ "title": "Yarn Art Style",
348
+ "repo": "linoyts/yarn-art-style", #28
349
+ "weights": "yarn-art-style_000001250.safetensors",
350
+ "trigger_word": "yarn art style"
351
+ },
352
+ {
353
+ "image": "https://huggingface.co/Quorlen/Z-Image-Turbo-Behind-Reeded-Glass-Lora/resolve/main/images/ComfyUI_00391_.png",
354
+ "title": "Behind Reeded Glass",
355
+ "repo": "Quorlen/Z-Image-Turbo-Behind-Reeded-Glass-Lora", #26
356
+ "weights": "Z_Image_Turbo_Behind_Reeded_Glass_Lora_TAV2_000002750.safetensors",
357
+ "trigger_word": "Act1vate!, Behind reeded glass"
358
+ },
359
+ {
360
+ "image": "https://huggingface.co/ostris/z_image_turbo_childrens_drawings/resolve/main/images/1764433619736__000003000_9.jpg",
361
+ "title": "Childrens Drawings",
362
+ "repo": "ostris/z_image_turbo_childrens_drawings", #2
363
+ "weights": "z_image_turbo_childrens_drawings.safetensors",
364
+ "trigger_word": "Children Drawings"
365
+ },
366
+ {
367
+ "image": "https://huggingface.co/strangerzonehf/Flux-Ultimate-LoRA-Collection/resolve/main/images/xcxc.png",
368
+ "title": "Tarot Z",
369
+ "repo": "multimodalart/tarot-z-image-lora", #22
370
+ "weights": "tarot-z-image_000001250.safetensors",
371
+ "trigger_word": "trtcrd"
372
+ },
373
+ {
374
+ "image": "https://huggingface.co/renderartist/Technically-Color-Z-Image-Turbo/resolve/main/images/ComfyUI_00917_.png",
375
+ "title": "Technically Color Z",
376
+ "repo": "renderartist/Technically-Color-Z-Image-Turbo", #3
377
+ "weights": "Technically_Color_Z_Image_Turbo_v1_renderartist_2000.safetensors",
378
+ "trigger_word": "t3chnic4lly"
379
+ },
380
+ {
381
+ "image": "https://huggingface.co/SkyAsl/Tattoo-artist-Z/resolve/main/images/a%20dragon%20with%20flames.png",
382
+ "title": "Tattoo-artist-Z",
383
+ "repo": "SkyAsl/Tattoo-artist-Z", #31
384
+ "weights": "adapter_model.safetensors",
385
+ "trigger_word": "a tattoo design"
386
+ },
387
+ {
388
+ "image": "https://huggingface.co/strangerzonehf/Flux-Ultimate-LoRA-Collection/resolve/main/images/z-image_00147_.png",
389
+ "title": "Turbo Ghibli",
390
+ "repo": "Ttio2/Z-Image-Turbo-Ghibli-Style", #19
391
+ "weights": "ghibli_zimage_finetune.safetensors",
392
+ "trigger_word": "Ghibli Style"
393
+ },
394
+ {
395
+ "image": "https://huggingface.co/tarn59/pixel_art_style_lora_z_image_turbo/resolve/main/images/ComfyUI_00273_.png",
396
+ "title": "Pixel Art",
397
+ "repo": "tarn59/pixel_art_style_lora_z_image_turbo", #4
398
+ "weights": "pixel_art_style_z_image_turbo.safetensors",
399
+ "trigger_word": "Pixel art style."
400
+ },
401
+ {
402
+ "image": "https://huggingface.co/renderartist/Saturday-Morning-Z-Image-Turbo/resolve/main/images/Saturday_Morning_Z_15.png",
403
+ "title": "Saturday Morning",
404
+ "repo": "renderartist/Saturday-Morning-Z-Image-Turbo", #5
405
+ "weights": "Saturday_Morning_Z_Image_Turbo_v1_renderartist_1250.safetensors",
406
+ "trigger_word": "saturd4ym0rning"
407
+ },
408
+ {
409
+ "image": "https://huggingface.co/AIImageStudio/ReversalFilmGravure_z_Image_turbo/resolve/main/images/2025-12-01_173047-z_image_z_image_turbo_bf16-435125750859057-euler_10_hires.png",
410
+ "title": "ReversalFilmGravure",
411
+ "repo": "AIImageStudio/ReversalFilmGravure_z_Image_turbo", #6
412
+ "weights": "z_image_turbo_ReversalFilmGravure_v1.0.safetensors",
413
+ "trigger_word": "Reversal Film Gravure, analog film photography"
414
+ },
415
+ {
416
+ "image": "https://huggingface.co/renderartist/Coloring-Book-Z-Image-Turbo-LoRA/resolve/main/images/CBZ_00274_.png",
417
+ "title": "Coloring Book Z",
418
+ "repo": "renderartist/Coloring-Book-Z-Image-Turbo-LoRA", #7
419
+ "weights": "Coloring_Book_Z_Image_Turbo_v1_renderartist_2000.safetensors",
420
+ "trigger_word": "c0l0ringb00k"
421
+ },
422
+ {
423
+ "image": "https://huggingface.co/damnthatai/1950s_American_Dream/resolve/main/images/ZImage_20251129163459_135x_00001_.jpg",
424
+ "title": "1950s American Dream",
425
+ "repo": "damnthatai/1950s_American_Dream", #8
426
+ "weights": "5os4m3r1c4n4_z.safetensors",
427
+ "trigger_word": "5os4m3r1c4n4, 1950s, painting, a painting of"
428
+ },
429
+ {
430
+ "image": "https://huggingface.co/wcde/Z-Image-Turbo-DeJPEG-Lora/resolve/main/images/01.png",
431
+ "title": "DeJPEG",
432
+ "repo": "wcde/Z-Image-Turbo-DeJPEG-Lora", #9
433
+ "weights": "dejpeg_v3.safetensors",
434
+ "trigger_word": ""
435
+ },
436
+ {
437
+ "image": "https://huggingface.co/suayptalha/Z-Image-Turbo-Realism-LoRA/resolve/main/images/n4aSpqa-YFXYo4dtcIg4W.png",
438
+ "title": "DeJPEG",
439
+ "repo": "suayptalha/Z-Image-Turbo-Realism-LoRA", #10
440
+ "weights": "pytorch_lora_weights.safetensors",
441
+ "trigger_word": "Realism"
442
+ },
443
+ {
444
+ "image": "https://huggingface.co/renderartist/Classic-Painting-Z-Image-Turbo-LoRA/resolve/main/images/Classic_Painting_Z_00247_.png",
445
+ "title": "Classic Painting Z",
446
+ "repo": "renderartist/Classic-Painting-Z-Image-Turbo-LoRA", #11
447
+ "weights": "Classic_Painting_Z_Image_Turbo_v1_renderartist_1750.safetensors",
448
+ "trigger_word": "class1cpa1nt"
449
+ },
450
+ {
451
+ "image": "https://huggingface.co/DK9/3D_MMORPG_style_z-image-turbo_lora/resolve/main/images/10_with_lora.png",
452
+ "title": "3D MMORPG",
453
+ "repo": "DK9/3D_MMORPG_style_z-image-turbo_lora", #12
454
+ "weights": "lostark_v1.safetensors",
455
+ "trigger_word": ""
456
+ },
457
+ {
458
+ "image": "https://huggingface.co/Danrisi/Olympus_UltraReal_ZImage/resolve/main/images/Z-Image_01011_.png",
459
+ "title": "Olympus UltraReal",
460
+ "repo": "Danrisi/Olympus_UltraReal_ZImage", #13
461
+ "weights": "Olympus.safetensors",
462
+ "trigger_word": "digital photography, early 2000s compact camera aesthetic, amateur candid shot, digital photography, early 2000s compact camera aesthetic, amateur candid shot, direct flash lighting, hard flash shadow, specular highlights, overexposed highlights"
463
+ },
464
+ {
465
+ "image": "https://huggingface.co/AiAF/D-ART_Z-Image-Turbo_LoRA/resolve/main/images/example_l3otpwzaz.png",
466
+ "title": "D ART Z Image",
467
+ "repo": "AiAF/D-ART_Z-Image-Turbo_LoRA", #14
468
+ "weights": "D-ART_Z-Image-Turbo.safetensors",
469
+ "trigger_word": "D-ART"
470
+ },
471
+ {
472
+ "image": "https://huggingface.co/AlekseyCalvin/Marionette_Modernism_Z-image-Turbo_LoRA/resolve/main/bluebirdmandoll.webp",
473
+ "title": "Marionette Modernism",
474
+ "repo": "AlekseyCalvin/Marionette_Modernism_Z-image-Turbo_LoRA", #15
475
+ "weights": "ZImageDadadoll_000003600.safetensors",
476
+ "trigger_word": "DADADOLL style"
477
+ },
478
+ {
479
+ "image": "https://huggingface.co/AlekseyCalvin/HistoricColor_Z-image-Turbo-LoRA/resolve/main/HSTZgen2.webp",
480
+ "title": "Historic Color Z",
481
+ "repo": "AlekseyCalvin/HistoricColor_Z-image-Turbo-LoRA", #16
482
+ "weights": "ZImage1HST_000004000.safetensors",
483
+ "trigger_word": "HST style"
484
+ },
485
+ {
486
+ "image": "https://huggingface.co/tarn59/80s_air_brush_style_z_image_turbo/resolve/main/images/ComfyUI_00707_.png",
487
+ "title": "80s Air Brush",
488
+ "repo": "tarn59/80s_air_brush_style_z_image_turbo", #17
489
+ "weights": "80s_air_brush_style_v2_z_image_turbo.safetensors",
490
+ "trigger_word": "80s Air Brush style."
491
+ },
492
+ {
493
+ "image": "https://huggingface.co/CedarC/Z-Image_360/resolve/main/images/1765505225357__000006750_6.jpg",
494
+ "title": "360panorama",
495
+ "repo": "CedarC/Z-Image_360", #18
496
+ "weights": "Z-Image_360.safetensors",
497
+ "trigger_word": "360panorama"
498
+ },
499
+ {
500
+ "image": "https://huggingface.co/HAV0X1014/Z-Image-Turbo-KF-Bat-Eared-Fox-LoRA/resolve/main/images/ComfyUI_00132_.png",
501
+ "title": "KF-Bat-Eared",
502
+ "repo": "HAV0X1014/Z-Image-Turbo-KF-Bat-Eared-Fox-LoRA", #21
503
+ "weights": "z-image-turbo-bat_eared_fox.safetensors",
504
+ "trigger_word": "bat_eared_fox_kemono_friends"
505
+ },
506
+ {
507
+ "image": "https://cdn-uploads.huggingface.co/production/uploads/653cd3049107029eb004f968/IHttgddXu6ZBMo7eyy8p6.png",
508
+ "title": "80s Horror",
509
+ "repo": "neph1/80s_horror_movies_lora_zit", #23
510
+ "weights": "80s_horror_z_80.safetensors",
511
+ "trigger_word": "80s_horror"
512
+ },
513
+ {
514
+ "image": "https://huggingface.co/Quorlen/z_image_turbo_Sunbleached_Protograph_Style_Lora/resolve/main/images/ComfyUI_00024_.png",
515
+ "title": "Sunbleached Protograph",
516
+ "repo": "Quorlen/z_image_turbo_Sunbleached_Protograph_Style_Lora", #24
517
+ "weights": "zimageturbo_Sunbleach_Photograph_Style_Lora_TAV2_000002750.safetensors",
518
+ "trigger_word": "Act1vate!"
519
+ },
520
+ {
521
+ "image": "https://huggingface.co/bunnycore/Z-Art-2.1/resolve/main/images/ComfyUI_00069_.png",
522
+ "title": "Z-Art-2.1",
523
+ "repo": "bunnycore/Z-Art-2.1", #25
524
+ "weights": "Z-Image-Art2.1.safetensors",
525
+ "trigger_word": "anime art"
526
+ },
527
+ {
528
+ "image": "https://huggingface.co/cactusfriend/longfurby-z/resolve/main/images/1764658860954__000003000_1.jpg",
529
+ "title": "Longfurby",
530
+ "repo": "cactusfriend/longfurby-z", #27
531
+ "weights": "longfurbyZ.safetensors",
532
+ "trigger_word": ""
533
+ },
534
+ {
535
+ "image": "https://huggingface.co/SkyAsl/Pixel-artist-Z/resolve/main/pixel-art-result.png",
536
+ "title": "Pixel Art",
537
+ "repo": "SkyAsl/Pixel-artist-Z", #29
538
+ "weights": "adapter_model.safetensors",
539
+ "trigger_word": "a pixel art character"
540
+ },
541
+ ]
542
+
543
+ dtype = torch.bfloat16
544
+ device = "cuda" if torch.cuda.is_available() else "cpu"
545
+ base_model = "Tongyi-MAI/Z-Image-Turbo"
546
+
547
+ print(f"Loading {base_model} pipeline...")
548
+
549
+ # Initialize Pipeline
550
+ pipe = ZImagePipeline.from_pretrained(
551
+ base_model,
552
+ torch_dtype=dtype,
553
+ low_cpu_mem_usage=False,
554
+ ).to(device)
555
+
556
+ # ======== AoTI compilation + FA3 ========
557
+ # As per reference for optimization
558
  try:
559
+ print("Applying AoTI compilation and FA3...")
560
+ pipe.transformer.layers._repeated_blocks = ["ZImageTransformerBlock"]
561
+ spaces.aoti_blocks_load(pipe.transformer.layers, "zerogpu-aoti/Z-Image", variant="fa3")
562
+ print("Optimization applied successfully.")
563
+ except Exception as e:
564
+ print(f"Optimization warning: {e}. Continuing with standard pipeline.")
565
+
566
+ MAX_SEED = np.iinfo(np.int32).max
567
+
568
+ class calculateDuration:
569
+ def __init__(self, activity_name=""):
570
+ self.activity_name = activity_name
571
+
572
+ def __enter__(self):
573
+ self.start_time = time.time()
574
+ return self
575
+
576
+ def __exit__(self, exc_type, exc_value, traceback):
577
+ self.end_time = time.time()
578
+ self.elapsed_time = self.end_time - self.start_time
579
+ if self.activity_name:
580
+ print(f"Elapsed time for {self.activity_name}: {self.elapsed_time:.6f} seconds")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
581
  else:
582
+ print(f"Elapsed time: {self.elapsed_time:.6f} seconds")
583
+
584
+ def update_selection(evt: gr.SelectData, width, height):
585
+ selected_lora = loras[evt.index]
586
+ new_placeholder = f"Type a prompt for {selected_lora['title']}"
587
+ lora_repo = selected_lora["repo"]
588
+ # 로컬 LoRA 처리
589
+ if lora_repo == "./":
590
+ updated_text = f"### Selected: Local LoRA - {selected_lora['title']} ✅"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  else:
592
+ updated_text = f"### Selected: [{lora_repo}](https://huggingface.co/{lora_repo}) ✅"
593
+
594
+ # Default aspect ratio
595
+ aspect = "1:1 (Instagram Square)"
596
+ width = 1024
597
+ height = 1024
598
+
599
+ if "aspect" in selected_lora:
600
+ if selected_lora["aspect"] == "portrait":
601
+ aspect = "9:16 (Instagram Reels/TikTok/Shorts)"
602
+ width = 768
603
+ height = 1344
604
+ elif selected_lora["aspect"] == "landscape":
605
+ aspect = "16:9 (YouTube/Twitter/X)"
606
+ width = 1344
607
+ height = 768
608
+
609
+ return (
610
+ gr.update(placeholder=new_placeholder),
611
+ updated_text,
612
+ evt.index,
613
+ aspect,
614
+ width,
615
+ height,
616
+ )
617
+
618
+ @spaces.GPU
619
+ def run_lora(prompt, image_input, image_strength, cfg_scale, steps, selected_index, randomize_seed, seed, width, height, lora_scale, progress=gr.Progress(track_tqdm=True)):
620
+ # Clean up previous LoRAs in both cases
621
+ with calculateDuration("Unloading LoRA"):
622
+ pipe.unload_lora_weights()
623
+
624
+ # Check if a LoRA is selected
625
+ if selected_index is not None and selected_index < len(loras):
626
+ selected_lora = loras[selected_index]
627
+ lora_path = selected_lora["repo"]
628
+ trigger_word = selected_lora["trigger_word"]
629
+
630
+ # Prepare Prompt with Trigger Word
631
+ if(trigger_word):
632
+ if "trigger_position" in selected_lora:
633
+ if selected_lora["trigger_position"] == "prepend":
634
+ prompt_mash = f"{trigger_word} {prompt}"
635
+ else:
636
+ prompt_mash = f"{prompt} {trigger_word}"
637
  else:
638
+ prompt_mash = f"{trigger_word} {prompt}"
639
+ else:
640
+ prompt_mash = prompt
641
 
642
+ # Load LoRA
643
+ with calculateDuration(f"Loading LoRA weights for {selected_lora['title']}"):
644
+ weight_name = selected_lora.get("weights", None)
 
 
 
 
 
645
  try:
646
+ pipe.load_lora_weights(
647
+ lora_path,
648
+ weight_name=weight_name,
649
+ adapter_name="default",
650
+ low_cpu_mem_usage=True
651
+ )
652
+ # Set adapter scale
653
+ pipe.set_adapters(["default"], adapter_weights=[lora_scale])
654
+ except Exception as e:
655
+ print(f"Error loading LoRA: {e}")
656
+ gr.Warning("Failed to load LoRA weights. Generating with base model.")
657
+ else:
658
+ # Base Model Case
659
+ print("No LoRA selected. Running with Base Model.")
660
+ prompt_mash = prompt
661
+
662
+ with calculateDuration("Randomizing seed"):
663
+ if randomize_seed:
664
+ seed = random.randint(0, MAX_SEED)
665
+
666
+ generator = torch.Generator(device=device).manual_seed(seed)
667
+
668
+ # Note: Z-Image-Turbo is strictly T2I in this reference implementation.
669
+ # Img2Img via image_input is disabled/ignored for this pipeline update.
670
+
671
+ with calculateDuration("Generating image"):
672
+ # For Turbo models, guidance_scale is typically 0.0
673
+ forced_guidance = 0.0 # Turbo mode
674
+
675
+ final_image = pipe(
676
+ prompt=prompt_mash,
677
+ height=int(height),
678
+ width=int(width),
679
+ num_inference_steps=int(steps),
680
+ guidance_scale=forced_guidance,
681
+ generator=generator,
682
+ ).images[0]
683
+
684
+ yield final_image, seed, gr.update(visible=False)
685
+
686
+ def get_huggingface_safetensors(link):
687
+ split_link = link.split("/")
688
+ if(len(split_link) == 2):
689
+ model_card = ModelCard.load(link)
690
+ base_model = model_card.data.get("base_model")
691
+ print(base_model)
692
+
693
+ # Relaxed check to allow Z-Image or Flux or others, assuming user knows what they are doing
694
+ # or specifically check for Z-Image-Turbo
695
+ if base_model not in ["Tongyi-MAI/Z-Image-Turbo", "black-forest-labs/FLUX.1-dev"]:
696
+ # Just a warning instead of error to allow experimentation
697
+ print("Warning: Base model might not match.")
698
 
699
+ image_path = model_card.data.get("widget", [{}])[0].get("output", {}).get("url", None)
700
+ trigger_word = model_card.data.get("instance_prompt", "")
701
+ image_url = f"https://huggingface.co/{link}/resolve/main/{image_path}" if image_path else None
702
+ fs = HfFileSystem()
703
+ try:
704
+ list_of_files = fs.ls(link, detail=False)
705
+ for file in list_of_files:
706
+ if(file.endswith(".safetensors")):
707
+ safetensors_name = file.split("/")[-1]
708
+ if (not image_url and file.lower().endswith((".jpg", ".jpeg", ".png", ".webp"))):
709
+ image_elements = file.split("/")
710
+ image_url = f"https://huggingface.co/{link}/resolve/main/{image_elements[-1]}"
711
+ except Exception as e:
712
+ print(e)
713
+ gr.Warning(f"You didn't include a link neither a valid Hugging Face repository with a *.safetensors LoRA")
714
+ raise Exception(f"You didn't include a link neither a valid Hugging Face repository with a *.safetensors LoRA")
715
+ return split_link[1], link, safetensors_name, trigger_word, image_url
716
+
717
+ def check_custom_model(link):
718
+ if(link.startswith("https://")):
719
+ if(link.startswith("https://huggingface.co") or link.startswith("https://www.huggingface.co")):
720
+ link_split = link.split("huggingface.co/")
721
+ return get_huggingface_safetensors(link_split[1])
722
+ else:
723
+ return get_huggingface_safetensors(link)
724
+
725
+ def add_custom_lora(custom_lora):
726
+ global loras
727
+ if(custom_lora):
728
  try:
729
+ title, repo, path, trigger_word, image = check_custom_model(custom_lora)
730
+ print(f"Loaded custom LoRA: {repo}")
731
+ card = f'''
732
+ <div class="custom_lora_card">
733
+ <span>Loaded custom LoRA:</span>
734
+ <div class="card_internal">
735
+ <img src="{image}" />
736
+ <div>
737
+ <h3>{title}</h3>
738
+ <small>{"Using: <code><b>"+trigger_word+"</code></b> as the trigger word" if trigger_word else "No trigger word found. If there's a trigger word, include it in your prompt"}<br></small>
739
+ </div>
740
+ </div>
741
+ </div>
742
+ '''
743
+ existing_item_index = next((index for (index, item) in enumerate(loras) if item['repo'] == repo), None)
744
+ if(not existing_item_index):
745
+ new_item = {
746
+ "image": image,
747
+ "title": title,
748
+ "repo": repo,
749
+ "weights": path,
750
+ "trigger_word": trigger_word
751
+ }
752
+ print(new_item)
753
+ existing_item_index = len(loras)
754
+ loras.append(new_item)
755
+
756
+ return gr.update(visible=True, value=card), gr.update(visible=True), gr.Gallery(selected_index=None), f"Custom: {path}", existing_item_index, trigger_word
757
+ except Exception as e:
758
+ gr.Warning(f"Invalid LoRA: either you entered an invalid link, or a non-supported LoRA")
759
+ return gr.update(visible=True, value=f"Invalid LoRA: either you entered an invalid link, a non-supported LoRA"), gr.update(visible=False), gr.update(), "", None, ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
760
  else:
761
+ return gr.update(visible=False), gr.update(visible=False), gr.update(), "", None, ""
762
+
763
+ def remove_custom_lora():
764
+ return gr.update(visible=False), gr.update(visible=False), gr.update(), "", None, ""
765
+
766
+ run_lora.zerogpu = True
767
+
768
+ with gr.Blocks(title="Z-IMAGE GEN/LORA", delete_cache=(60, 60)) as demo:
769
+ gr.LoginButton(value="Option: HuggingFace 'Login' for extra GPU quota +", size="sm")
770
+ # HOME Button
771
+ gr.HTML("""
772
+ <div class="home-button-container">
773
+ <a href="https://www.humangen.ai" target="_blank" class="home-button">
774
+ 🏠 HOME
775
+ </a>
776
+ <span class="url-display">🌐 www.humangen.ai</span>
777
+ </div>
778
+ """)
779
+
780
+ # Header
781
+ gr.HTML("""
782
+ <div class="header-container">
783
+ <div class="header-title">🎨 Z-IMAGE GEN/LORA 🎨</div>
784
+ <div class="header-subtitle">Generate amazing images with Z-Image Turbo and various LoRA styles!</div>
785
+ <div style="margin-top:12px">
786
+ <span class="stats-badge">⚡ Turbo Speed</span>
787
+ <span class="stats-badge">🎭 30+ LoRAs</span>
788
+ <span class="stats-badge">🖼️ High Quality</span>
789
+ <span class="stats-badge">🔧 Custom LoRA</span>
790
+ </div>
791
+ </div>
792
+ """)
793
+
794
+ selected_index = gr.State(None)
795
+ with gr.Row():
796
+ with gr.Column(scale=3):
797
+ prompt = gr.Textbox(label="Enter Prompt", lines=1, placeholder="✦︎ Choose the LoRA and type the prompt (LoRA = None → Base Model = Active)")
798
+ with gr.Column(scale=1, elem_id="gen_column"):
799
+ generate_button = gr.Button("🚀 Generate", variant="primary", elem_id="gen_btn")
800
+ with gr.Row():
801
+ with gr.Column():
802
+ selected_info = gr.Markdown("### No LoRA Selected (Base Model)")
803
+ gallery = gr.Gallery(
804
+ [(item["image"], item["title"]) for item in loras],
805
+ label="Z-Image LoRAs",
806
+ allow_preview=False,
807
+ columns=3,
808
+ elem_id="gallery",
809
+ )
810
+ with gr.Group():
811
+ custom_lora = gr.Textbox(label="Enter Custom LoRA", placeholder="Paste the LoRA path and press Enter (e.g., Shakker-Labs/AWPortrait-Z).")
812
+ gr.Markdown("[Check the list of Z-Image LoRA's](https://huggingface.co/models?other=base_model:adapter:Tongyi-MAI/Z-Image-Turbo)", elem_id="lora_list")
813
+ custom_lora_info = gr.HTML(visible=False)
814
+ custom_lora_button = gr.Button("Remove custom LoRA", visible=False)
815
+ with gr.Column():
816
+ progress_bar = gr.Markdown(elem_id="progress",visible=False)
817
+ result = gr.Image(label="Generated Image", format="png", height=630)
818
+
819
+ # SNS Aspect Ratio Presets
820
+ ASPECT_RATIOS = {
821
+ "1:1 (Instagram Square)": (1024, 1024),
822
+ "9:16 (Instagram Reels/TikTok/Shorts)": (768, 1344),
823
+ "16:9 (YouTube/Twitter/X)": (1344, 768),
824
+ "4:5 (Instagram Portrait)": (896, 1120),
825
+ "5:4 (Instagram Landscape)": (1120, 896),
826
+ "3:4 (Portrait Photo)": (896, 1152),
827
+ "4:3 (Landscape Photo)": (1152, 896),
828
+ "2:3 (Pinterest)": (832, 1248),
829
+ "3:2 (Classic Photo)": (1248, 832),
830
+ "21:9 (Cinematic Ultra-wide)": (1344, 576),
831
+ "9:21 (Tall Banner)": (576, 1344),
832
+ }
833
+
834
+ def update_size(aspect_ratio):
835
+ width, height = ASPECT_RATIOS.get(aspect_ratio, (1024, 1024))
836
+ return width, height
837
+
838
+ with gr.Row():
839
+ with gr.Accordion("⚙️ Advanced Settings", open=True):
840
+ with gr.Row():
841
+ input_image = gr.Image(label="Input image (Ignored for Z-Image-Turbo)", type="filepath", visible=False)
842
+ image_strength = gr.Slider(label="Denoise Strength", info="Ignored for Z-Image-Turbo", minimum=0.1, maximum=1.0, step=0.01, value=0.75, visible=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
843
 
844
+ gr.HTML('<div class="info-box">📐 <b>Image Size</b> - Select aspect ratio for different platforms</div>')
 
 
 
 
 
 
845
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
846
  with gr.Row():
847
+ aspect_ratio = gr.Dropdown(
848
+ choices=list(ASPECT_RATIOS.keys()),
849
+ value="1:1 (Instagram Square)",
850
+ label="📱 Aspect Ratio (SNS Presets)",
851
+ info="Choose the best ratio for your target platform"
852
+ )
 
853
 
854
+ with gr.Row():
855
+ width = gr.Slider(label="Width", minimum=256, maximum=1536, step=64, value=1536)
856
+ height = gr.Slider(label="Height", minimum=256, maximum=1536, step=64, value=1536)
857
 
858
+ with gr.Column():
859
+ with gr.Row():
860
+ cfg_scale = gr.Slider(label="CFG Scale", info="Forced to 0.0 for Turbo", minimum=0, maximum=20, step=0.5, value=0.0, interactive=False)
861
+ steps = gr.Slider(label="Steps", minimum=1, maximum=50, step=1, value=25)
862
+
863
+ with gr.Row():
864
+ randomize_seed = gr.Checkbox(True, label="Randomize seed")
865
+ seed = gr.Slider(label="Seed", minimum=0, maximum=MAX_SEED, step=1, value=0, randomize=True)
866
+ lora_scale = gr.Slider(label="LoRA Scale", minimum=0, maximum=3, step=0.01, value=0.95)
867
+
868
+ # Connect aspect ratio dropdown to width/height sliders
869
+ aspect_ratio.change(
870
+ fn=update_size,
871
+ inputs=[aspect_ratio],
872
+ outputs=[width, height]
873
+ )
874
+
875
+ # Footer
876
+ gr.HTML("""
877
+ <div class="footer-comic">
878
+ <p style="font-family:'Bangers',cursive;font-size:1.5rem;letter-spacing:2px">🎨 Z-IMAGE GEN/LORA 🎨</p>
879
+ <p>Powered by Z-Image Turbo + LoRA Adapters</p>
880
+ <p>⚡ Fast Generation • 🎭 Multiple Styles • 🖼️ High Quality</p>
881
+ <p style="margin-top:10px"><a href="https://www.humangen.ai" target="_blank" style="color:#FACC15;text-decoration:none;font-weight:bold;">🏠 www.humangen.ai</a></p>
882
+ </div>
883
+ """)
884
+
885
+ gallery.select(
886
+ update_selection,
887
+ inputs=[width, height],
888
+ outputs=[prompt, selected_info, selected_index, aspect_ratio, width, height]
889
+ )
890
+ custom_lora.input(
891
+ add_custom_lora,
892
+ inputs=[custom_lora],
893
+ outputs=[custom_lora_info, custom_lora_button, gallery, selected_info, selected_index, prompt]
894
+ )
895
+ custom_lora_button.click(
896
+ remove_custom_lora,
897
+ outputs=[custom_lora_info, custom_lora_button, gallery, selected_info, selected_index, custom_lora]
898
+ )
899
+ gr.on(
900
+ triggers=[generate_button.click, prompt.submit],
901
+ fn=run_lora,
902
+ inputs=[prompt, input_image, image_strength, cfg_scale, steps, selected_index, randomize_seed, seed, width, height, lora_scale],
903
+ outputs=[result, seed, progress_bar]
904
+ )
905
+
906
+ demo.queue()
907
+ demo.launch(css=COMIC_CSS, ssr_mode=False, show_error=True)