tomo2chin2 commited on
Commit
697fed0
·
verified ·
1 Parent(s): 134a130

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +284 -891
app.py CHANGED
@@ -1,53 +1,65 @@
1
- import gradio as gr
2
- from fastapi import FastAPI, HTTPException, Body
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from fastapi.responses import StreamingResponse
4
- from fastapi.staticfiles import StaticFiles
5
  from fastapi.middleware.cors import CORSMiddleware
6
  from pydantic import BaseModel
 
7
  from selenium import webdriver
8
  from selenium.webdriver.chrome.options import Options
9
  from selenium.webdriver.common.by import By
10
  from selenium.webdriver.support.ui import WebDriverWait
11
  from selenium.webdriver.support import expected_conditions as EC
12
- from PIL import Image
13
- from io import BytesIO
14
- import tempfile
15
- import time
16
- import os
17
- import logging
18
- import numpy as np # 追加: 画像処理の最適化用
19
- import threading # 追加: 並列処理のため
20
- import queue # 追加: WebDriverプール用
21
- from concurrent.futures import ThreadPoolExecutor # 追加: 並列処理用
22
- from huggingface_hub import hf_hub_download
23
 
24
- # 正しいGemini関連のインポート
25
- import google.generativeai as genai
26
 
27
- # ロギング設定
28
  logging.basicConfig(level=logging.INFO)
29
  logger = logging.getLogger(__name__)
30
 
31
- # --- WebDriverプールの実装 ---
 
 
32
  class WebDriverPool:
33
- """WebDriverインスタンスを再利用するためのプール"""
34
- def __init__(self, max_drivers=3):
35
  self.driver_queue = queue.Queue()
36
  self.max_drivers = max_drivers
37
  self.lock = threading.Lock()
38
  self.count = 0
39
- logger.info(f"WebDriverプールを初期化: 最大 {max_drivers} ドライバー")
40
-
41
  def get_driver(self):
42
- """プールからWebDriverを取得、なければ新規作成"""
43
  if not self.driver_queue.empty():
44
- logger.info("既存のWebDriverをプールから取得")
45
  return self.driver_queue.get()
46
-
 
47
  with self.lock:
48
  if self.count < self.max_drivers:
49
  self.count += 1
50
- logger.info(f"新しいWebDriverを作成 (合計: {self.count}/{self.max_drivers})")
51
  options = Options()
52
  options.add_argument("--headless")
53
  options.add_argument("--no-sandbox")
@@ -55,42 +67,36 @@ class WebDriverPool:
55
  options.add_argument("--force-device-scale-factor=1")
56
  options.add_argument("--disable-features=NetworkService")
57
  options.add_argument("--dns-prefetch-disable")
58
-
59
- # 環境変数からWebDriverパスを取得(任意)
60
- webdriver_path = os.environ.get("CHROMEDRIVER_PATH")
61
- if webdriver_path and os.path.exists(webdriver_path):
62
- logger.info(f"CHROMEDRIVER_PATH使用: {webdriver_path}")
63
- service = webdriver.ChromeService(executable_path=webdriver_path)
64
  return webdriver.Chrome(service=service, options=options)
65
- else:
66
- logger.info("デフォルトのChromeDriverを使用")
67
- return webdriver.Chrome(options=options)
68
-
69
- # 最大数に達した場合は待機
70
- logger.info("WebDriverプールがいっぱいです。利用可能なドライバーを待機中...")
71
  return self.driver_queue.get()
72
-
73
  def release_driver(self, driver):
74
- """ドライバーをプールに戻す"""
75
  if driver:
76
  try:
77
- # ブラウザをリセット
78
  driver.get("about:blank")
79
  driver.execute_script("""
80
- document.documentElement.style.overflow = '';
81
- document.body.style.overflow = '';
82
  """)
83
  self.driver_queue.put(driver)
84
- logger.info("WebDriverをプールに戻しました")
85
  except Exception as e:
86
- logger.error(f"ドライバーをプールに戻す際にエラー: {e}")
87
  driver.quit()
88
  with self.lock:
89
  self.count -= 1
90
-
91
  def close_all(self):
92
- """全てのドライバーを終了"""
93
- logger.info("WebDriverプールを終了します")
94
  closed = 0
95
  while not self.driver_queue.empty():
96
  try:
@@ -100,917 +106,304 @@ class WebDriverPool:
100
  except queue.Empty:
101
  break
102
  except Exception as e:
103
- logger.error(f"ドライバー終了中にエラー: {e}")
104
-
105
- logger.info(f"{closed}個のWebDriverを終了しました")
106
  with self.lock:
107
  self.count = 0
 
108
 
109
- # グローバルなWebDriverプールを作成
110
- # サーバー環境のリソースに合わせて調整
111
  driver_pool = WebDriverPool(max_drivers=int(os.environ.get("MAX_WEBDRIVERS", "3")))
112
 
113
- # --- Gemini統合 ---
 
 
114
  class GeminiRequest(BaseModel):
115
- """Geminiへのリクエストデータモデル"""
116
  text: str
117
- extension_percentage: float = 10.0 # デフォルト値10%
118
- temperature: float = 0.5 # デフォルト値を0.5に設定
119
- trim_whitespace: bool = True # 余白トリミングオプション(デフォルト有効)
120
- style: str = "standard" # デフォルトはstandard
121
 
122
  class ScreenshotRequest(BaseModel):
123
- """スクリーンショットリクエストモデル"""
124
  html_code: str
125
- extension_percentage: float = 10.0 # デフォルト値10%
126
- trim_whitespace: bool = True # 余白トリミングオプション(デフォルト有効)
127
- style: str = "standard" # デフォルトはstandard
128
-
129
- # HTMLのFont Awesomeレイアウトを改善する関数 - プリロード機能を追加
130
- def enhance_font_awesome_layout(html_code):
131
- """Font Awesomeレイアウトを改善し、プリロードタグを追加"""
132
- # Font Awesomeリソースのプリロード - パフォーマンス向上
 
133
  fa_preload = """
134
  <link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/webfonts/fa-solid-900.woff2" as="font" type="font/woff2" crossorigin>
135
  <link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/webfonts/fa-regular-400.woff2" as="font" type="font/woff2" crossorigin>
136
  <link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/webfonts/fa-brands-400.woff2" as="font" type="font/woff2" crossorigin>
137
  """
138
-
139
- # CSSを追加
140
- fa_fix_css = """
141
  <style>
142
- /* Font Awesomeアイコンのレイアウト修正 */
143
- [class*="fa-"] {
144
- display: inline-block !important;
145
- margin-right: 8px !important;
146
- vertical-align: middle !important;
147
- }
148
-
149
- /* テキスト内のアイコン位置調整 */
150
- h1 [class*="fa-"], h2 [class*="fa-"], h3 [class*="fa-"],
151
- h4 [class*="fa-"], h5 [class*="fa-"], h6 [class*="fa-"] {
152
- vertical-align: middle !important;
153
- margin-right: 10px !important;
154
- }
155
-
156
- /* 特定パターンの修正 */
157
- .fa + span, .fas + span, .far + span, .fab + span,
158
- span + .fa, span + .fas, span + .far + span {
159
- display: inline-block !important;
160
- margin-left: 5px !important;
161
- }
162
-
163
- /* カード内アイコン修正 */
164
- .card [class*="fa-"], .card-body [class*="fa-"] {
165
- float: none !important;
166
- clear: none !important;
167
- position: relative !important;
168
- }
169
-
170
- /* アイコンと文字が重なる場合の調整 */
171
- li [class*="fa-"], p [class*="fa-"] {
172
- margin-right: 10px !important;
173
- }
174
-
175
- /* インラインアイコンのスペーシング */
176
- .inline-icon {
177
- display: inline-flex !important;
178
- align-items: center !important;
179
- justify-content: flex-start !important;
180
- }
181
-
182
- /* アイコン後のテキスト */
183
- [class*="fa-"] + span {
184
- display: inline-block !important;
185
- vertical-align: middle !important;
186
- }
187
  </style>
188
  """
189
-
190
- # headタグがある場合はその中に追加
191
  if '<head>' in html_code:
192
- return html_code.replace('</head>', f'{fa_preload}{fa_fix_css}</head>')
193
- # HTMLタグがある場合はその後に追加
194
  elif '<html' in html_code:
195
  head_end = html_code.find('</head>')
196
  if head_end > 0:
197
- return html_code[:head_end] + fa_preload + fa_fix_css + html_code[head_end:]
198
- else:
199
- body_start = html_code.find('<body')
200
- if body_start > 0:
201
- return html_code[:body_start] + f'<head>{fa_preload}{fa_fix_css}</head>' + html_code[body_start:]
202
-
203
- # どちらもない場合は先頭に追加
204
- return f'<html><head>{fa_preload}{fa_fix_css}</head>' + html_code + '</html>'
 
 
 
 
 
 
 
 
 
205
 
206
- def load_system_instruction(style="standard"):
207
- """
208
- 指定されたスタイルのシステムインストラクションを読み込む
209
-
210
- Args:
211
- style: 使用するスタイル名 (standard, cute, resort, cool, dental, school)
212
-
213
- Returns:
214
- 読み込まれたシステムインストラクション
215
- """
216
  try:
217
- # 有効なスタイル一覧
218
- valid_styles = ["standard", "cute", "resort", "cool", "dental", "school", "KOKUGO"]
219
-
220
- # スタイルの検証
221
- if style not in valid_styles:
222
- logger.warning(f"無効なスタイル '{style}' が指定されました。デフォルトの 'standard' を使用します。")
223
- style = "standard"
224
-
225
- logger.info(f"スタイル '{style}' のシステムインストラクションを読み込みます")
226
-
227
- # まず、ローカルのスタイルディレクトリ内のprompt.txtを確認
228
- local_path = os.path.join(os.path.dirname(__file__), style, "prompt.txt")
229
-
230
- # ローカルファイルが存在する場合はそれを使用
231
- if os.path.exists(local_path):
232
- logger.info(f"ローカルファイルを使用: {local_path}")
233
- with open(local_path, 'r', encoding='utf-8') as file:
234
- instruction = file.read()
235
- return instruction
236
-
237
- # HuggingFaceリポジトリからのファイル読み込みを試行
238
- try:
239
- # スタイル固有のファイルパスを指定
240
- file_path = hf_hub_download(
241
- repo_id="tomo2chin2/GURAREKOstlyle",
242
- filename=f"{style}/prompt.txt",
243
- repo_type="dataset"
244
- )
245
-
246
- logger.info(f"スタイル '{style}' のプロンプトをHuggingFaceから読み込みました: {file_path}")
247
- with open(file_path, 'r', encoding='utf-8') as file:
248
- instruction = file.read()
249
- return instruction
250
-
251
- except Exception as style_error:
252
- # スタイル固有ファイルの読み込みに失敗した場合、デフォルトのprompt.txtを使用
253
- logger.warning(f"スタイル '{style}' のプロンプト読み込みに失敗: {str(style_error)}")
254
- logger.info("デフォルトのprompt.txtを読み込みます")
255
-
256
- file_path = hf_hub_download(
257
- repo_id="tomo2chin2/GURAREKOstlyle",
258
- filename="prompt.txt",
259
- repo_type="dataset"
260
- )
261
-
262
- with open(file_path, 'r', encoding='utf-8') as file:
263
- instruction = file.read()
264
-
265
- logger.info("デフォルトのシステムインストラクションを読み込みました")
266
- return instruction
267
-
268
  except Exception as e:
269
- error_msg = f"システムインストラクションの読み込みに失敗: {str(e)}"
270
- logger.error(error_msg)
271
- raise ValueError(error_msg)
272
-
273
- def generate_html_from_text(text, temperature=0.5, style="standard"):
274
- """テキストからHTMLを生成する"""
275
- try:
276
- # APIキーの取得と設定
277
- api_key = os.environ.get("GEMINI_API_KEY")
278
- if not api_key:
279
- logger.error("GEMINI_API_KEY 環境変数が設定されていません")
280
- raise ValueError("GEMINI_API_KEY 環境変数が設定されていません")
281
-
282
- # モデル名の取得(環境変数から、なければデフォルト値)
283
- model_name = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
284
- logger.info(f"使用するGeminiモデル: {model_name}")
285
-
286
- # Gemini APIの設定
287
- genai.configure(api_key=api_key)
288
-
289
- # 指定されたスタイルのシステムインストラクションを読み込む
290
- system_instruction = load_system_instruction(style)
291
-
292
- # モデル初期化
293
- logger.info(f"Gemini APIにリクエストを送信: テキスト長さ = {len(text)}, 温度 = {temperature}, スタイル = {style}")
294
-
295
- # モデル初期化
296
- model = genai.GenerativeModel(model_name)
297
-
298
- # 生成設定 - ばらつきを減らすために設定を調整
299
- generation_config = {
300
- "temperature": temperature, # より低い温度を設定
301
- "top_p": 0.7, # 0.95から0.7に下げて出力の多様性を制限
302
- "top_k": 20, # 64から20に下げて候補を絞る
303
- "max_output_tokens": 8192,
304
- "candidate_count": 1 # 候補は1つだけ生成
305
- }
306
-
307
- # 安全設定 - デフォルトの安全設定を使用
308
- safety_settings = [
309
- {
310
- "category": "HARM_CATEGORY_HARASSMENT",
311
- "threshold": "BLOCK_MEDIUM_AND_ABOVE"
312
- },
313
- {
314
- "category": "HARM_CATEGORY_HATE_SPEECH",
315
- "threshold": "BLOCK_MEDIUM_AND_ABOVE"
316
- },
317
- {
318
- "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
319
- "threshold": "BLOCK_MEDIUM_AND_ABOVE"
320
- },
321
- {
322
- "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
323
- "threshold": "BLOCK_MEDIUM_AND_ABOVE"
324
- }
325
- ]
326
-
327
- # プロンプト構築
328
- prompt = f"{system_instruction}\n\n{text}"
329
-
330
- # コンテンツ生成
331
- response = model.generate_content(
332
- prompt,
333
- generation_config=generation_config,
334
- safety_settings=safety_settings
335
  )
 
 
336
 
337
- # レスポンスからHTMLを抽出
338
- raw_response = response.text
339
-
340
- # HTMLタグ部分だけを抽出(```html と ``` の間)
341
- html_start = raw_response.find("```html")
342
- html_end = raw_response.rfind("```")
343
-
344
- if html_start != -1 and html_end != -1 and html_start < html_end:
345
- html_start += 7 # "```html" の長さ分進める
346
- html_code = raw_response[html_start:html_end].strip()
347
- logger.info(f"HTMLの生成に成功: 長さ = {len(html_code)}")
348
-
349
- # Font Awesomeのレイアウト改善
350
- html_code = enhance_font_awesome_layout(html_code)
351
- logger.info("Font Awesomeレイアウトの最適化を適用しました")
352
 
353
- return html_code
354
- else:
355
- # HTMLタグが見つからない場合、レスポンス全体を返す
356
- logger.warning("レスポンスから ```html ``` タグが見つかりませんでした。全テキストを返します。")
357
- return raw_response
358
 
359
- except Exception as e:
360
- logger.error(f"HTML生成中にエラーが発生: {e}", exc_info=True)
361
- raise Exception(f"Gemini APIでのHTML生成に失敗しました: {e}")
362
 
363
- # 画像から余分な空白領域をトリミングする関数 - NumPyを使って最適化
364
- def trim_image_whitespace(image, threshold=250, padding=10):
365
- """
366
- NumPyを使用して最適化された画像トリミング関数
367
-
368
- Args:
369
- image: PIL.Image - 入力画像
370
- threshold: int - どの明るさ以上を空白と判断するか (0-255)
371
- padding: int - トリミング後に残す余白のピクセル数
372
-
373
- Returns:
374
- トリミングされたPIL.Image
375
- """
376
- try:
377
- # グレースケールに変換
378
- gray = image.convert('L')
379
-
380
- # NumPy配列として取得(高速処理のため)
381
- np_image = np.array(gray)
382
-
383
- # マスク作成(非白ピクセル)
384
- mask = np_image < threshold
385
-
386
- # マスクから行と列のインデックスを取得
387
- rows = np.any(mask, axis=1)
388
- cols = np.any(mask, axis=0)
389
-
390
- # 非空のインデックス範囲を取得
391
- if np.any(rows) and np.any(cols):
392
- row_indices = np.where(rows)[0]
393
- col_indices = np.where(cols)[0]
394
-
395
- # 範囲取得
396
- min_y, max_y = row_indices[0], row_indices[-1]
397
- min_x, max_x = col_indices[0], col_indices[-1]
398
-
399
- # パディング追加
400
- min_x = max(0, min_x - padding)
401
- min_y = max(0, min_y - padding)
402
- max_x = min(image.width - 1, max_x + padding)
403
- max_y = min(image.height - 1, max_y + padding)
404
-
405
- # 画像をトリミング
406
- trimmed = image.crop((min_x, min_y, max_x + 1, max_y + 1))
407
-
408
- logger.info(f"画像をトリミングしました: 元サイズ {image.width}x{image.height} → トリミング後 {trimmed.width}x{trimmed.height}")
409
- return trimmed
410
-
411
- logger.warning("トリミング領域が見つかりません。元の画像を返します。")
412
- return image
413
-
414
- except Exception as e:
415
- logger.error(f"画像トリミング中にエラー: {e}", exc_info=True)
416
- return image # エラー時は元の画像を返す
417
-
418
- # 最適化されたスクリーンショット生成関数 - 外部から初期化済みドライバーを受け取れるように
419
  def render_fullpage_screenshot(html_code: str, extension_percentage: float = 6.0,
420
- trim_whitespace: bool = True, driver=None) -> Image.Image:
421
- """
422
- Renders HTML code to a full-page screenshot using Selenium.
423
- Optimized to accept an external driver or get one from the pool.
424
-
425
- Args:
426
- html_code: The HTML source code string.
427
- extension_percentage: Percentage of extra space to add vertically.
428
- trim_whitespace: Whether to trim excess whitespace from the image.
429
- driver: An optional pre-initialized WebDriver instance.
430
-
431
- Returns:
432
- A PIL Image object of the screenshot.
433
- """
434
- tmp_path = None
435
  driver_from_pool = False
436
-
437
- # ドライバーがない場合はプールから取得
438
- if driver is None:
439
- driver = driver_pool.get_driver()
440
- driver_from_pool = True
441
- logger.info("WebDriverプールからドライバーを取得しました")
442
-
443
- # 1) Save HTML code to a temporary file
444
- try:
445
- with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode='w', encoding='utf-8') as tmp_file:
446
- tmp_path = tmp_file.name
447
- tmp_file.write(html_code)
448
- logger.info(f"HTML saved to temporary file: {tmp_path}")
449
- except Exception as e:
450
- logger.error(f"Error writing temporary HTML file: {e}")
451
- if driver_from_pool:
452
- driver_pool.release_driver(driver)
453
- return Image.new('RGB', (1, 1), color=(0, 0, 0))
454
-
455
  try:
456
- # ウィンドウサイズ初期設定
457
- initial_width = 1200
458
- initial_height = 1000
459
- driver.set_window_size(initial_width, initial_height)
460
- file_url = "file://" + tmp_path
461
- logger.info(f"Navigating to {file_url}")
462
- driver.get(file_url)
463
-
464
- # ページ読み込み待機 - 動的な待機時間を実装
465
- logger.info("Waiting for body element...")
466
- WebDriverWait(driver, 10).until(
467
- EC.presence_of_element_located((By.TAG_NAME, "body"))
468
- )
469
- logger.info("Body element found. Waiting for resource loading...")
470
-
471
- # リソース読み込みの動的待機 - 最適化
472
- max_wait = 5 # 最大待機時間(秒)
473
- wait_increment = 0.2 # 確認間隔
474
- wait_time = 0
475
-
476
- while wait_time < max_wait:
477
- resource_state = driver.execute_script("""
478
- return {
479
- complete: document.readyState === 'complete',
480
- imgCount: document.images.length,
481
- imgLoaded: Array.from(document.images).filter(img => img.complete).length,
482
- faElements: document.querySelectorAll('.fa, .fas, .far, .fab, [class*="fa-"]').length
483
- };
484
- """)
485
-
486
- # ドキュメント完了かつ画像が読み込まれている場合、待機終了
487
- if resource_state['complete'] and (resource_state['imgCount'] == 0 or
488
- resource_state['imgLoaded'] == resource_state['imgCount']):
489
- logger.info(f"リソース読み込み完了: {resource_state}")
490
- break
491
-
492
- time.sleep(wait_increment)
493
- wait_time += wait_increment
494
- logger.info(f"リソース待機中... {wait_time:.1f}秒経過, 状態: {resource_state}")
495
-
496
- # Font Awesome要素が多い場合は追加待機
497
- fa_count = resource_state.get('faElements', 0)
498
- if fa_count > 30:
499
- logger.info(f"{fa_count}個のFont Awesome要素があるため、追加待機...")
500
- time.sleep(min(1.0, fa_count / 100)) # 要素数に応じて待機(最大1秒)
501
-
502
- # コンテンツレンダリングのためのスクロール処理 - パフォーマンス改善
503
- logger.info("Performing content rendering scroll...")
504
- total_height = driver.execute_script("return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);")
505
- viewport_height = driver.execute_script("return window.innerHeight;")
506
- scrolls_needed = max(1, min(5, total_height // viewport_height)) # 最大5回までに制限
507
-
508
- # スクロール処理の高速化
509
- for i in range(scrolls_needed):
510
- scroll_pos = i * (viewport_height - 100) # 少しだけオーバーラップ
511
- driver.execute_script(f"window.scrollTo(0, {scroll_pos});")
512
- time.sleep(0.1) # 高速化のため待機時間短縮
513
-
514
- # トップに戻る
515
- driver.execute_script("window.scrollTo(0, 0);")
516
- time.sleep(0.2) # 短い待機に変更
517
- logger.info("Scroll rendering completed")
518
-
519
- # スクロールバーを非表示に
520
- driver.execute_script("""
521
- document.documentElement.style.overflow = 'hidden';
522
- document.body.style.overflow = 'hidden';
523
- """)
524
-
525
- # ページの寸法を取得
526
- dimensions = driver.execute_script("""
527
  return {
528
- width: Math.max(
529
- document.documentElement.scrollWidth,
530
- document.documentElement.offsetWidth,
531
- document.documentElement.clientWidth,
532
- document.body ? document.body.scrollWidth : 0,
533
- document.body ? document.body.offsetWidth : 0,
534
- document.body ? document.body.clientWidth : 0
535
- ),
536
- height: Math.max(
537
- document.documentElement.scrollHeight,
538
- document.documentElement.offsetHeight,
539
- document.documentElement.clientHeight,
540
- document.body ? document.body.scrollHeight : 0,
541
- document.body ? document.body.offsetHeight : 0,
542
- document.body ? document.body.clientHeight : 0
543
- )
544
- };
545
  """)
546
- scroll_width = dimensions['width']
547
- scroll_height = dimensions['height']
548
- logger.info(f"Detected dimensions: width={scroll_width}, height={scroll_height}")
549
-
550
- # 最小/最大値の設定
551
- scroll_width = max(scroll_width, 100)
552
- scroll_height = max(scroll_height, 100)
553
- scroll_width = min(scroll_width, 2000)
554
- scroll_height = min(scroll_height, 4000)
555
-
556
- # レイアウト安定化のための待機
557
- time.sleep(2.0)
558
-
559
- # 高さに余白を追加
560
- adjusted_height = int(scroll_height * (1 + extension_percentage / 100.0))
561
- adjusted_height = max(adjusted_height, scroll_height, 100)
562
-
563
- # ウィンドウサイズを調整
564
- adjusted_width = scroll_width
565
- logger.info(f"Resizing window to: width={adjusted_width}, height={adjusted_height}")
566
- driver.set_window_size(adjusted_width, adjusted_height)
567
- time.sleep(0.5) # 短縮した待機時間
568
-
569
- # スクリーンショット取得
570
- logger.info("Taking screenshot...")
571
- png = driver.get_screenshot_as_png()
572
- logger.info("Screenshot taken successfully.")
573
 
574
- # PIL画像に変換
575
  img = Image.open(BytesIO(png))
576
- logger.info(f"Screenshot dimensions: {img.width}x{img.height}")
577
-
578
- # 余白トリミング - 最適化版を使用
579
  if trim_whitespace:
580
  img = trim_image_whitespace(img, threshold=248, padding=20)
581
- logger.info(f"Trimmed dimensions: {img.width}x{img.height}")
582
-
583
  return img
584
-
585
  except Exception as e:
586
- logger.error(f"Error during screenshot generation: {e}", exc_info=True)
587
- # エラー時は小さい黒画像を返す
588
- return Image.new('RGB', (1, 1), color=(0, 0, 0))
589
  finally:
590
- logger.info("Cleaning up...")
591
- # WebDriverプールに戻す
592
  if driver_from_pool:
593
  driver_pool.release_driver(driver)
594
- logger.info("Returned driver to pool")
595
- # 一時ファイル削除
596
  if tmp_path and os.path.exists(tmp_path):
597
  try:
598
  os.remove(tmp_path)
599
- logger.info(f"Temporary file {tmp_path} removed.")
600
- except Exception as e:
601
- logger.error(f"Error removing temporary file {tmp_path}: {e}")
602
 
603
- # --- 並列処理を活用した新しい関数 ---
 
 
604
  def text_to_screenshot_parallel(text: str, extension_percentage: float, temperature: float = 0.5,
605
- trim_whitespace: bool = True, style: str = "standard") -> Image.Image:
606
- """テキストをGemini APIでHTMLに変換し、並列処理でスクリーンショットを生成する関数"""
607
- start_time = time.time()
608
- logger.info("並列処理によるテキスト→スクリーンショット生成を開始")
609
-
610
- try:
611
- # WebDriverと HTML生成を並列で実行
612
- with ThreadPoolExecutor(max_workers=2) as executor:
613
- # Gemini APIリクエストタスク
614
- html_future = executor.submit(
615
- generate_html_from_text,
616
- text=text,
617
- temperature=temperature,
618
- style=style
619
- )
620
-
621
- # WebDriver初期化タスク - プール使用
622
- driver_future = executor.submit(driver_pool.get_driver)
623
-
624
- # 結果を取得
625
- html_code = html_future.result()
626
- driver = driver_future.result()
627
-
628
- # ドライバーはプールから取得しているためフラグ設定
629
- driver_from_pool = True
630
-
631
- # HTMLコードとドライバーが準備できたらスクリーンショット生成
632
- logger.info(f"HTML生成完了:{len(html_code)}文字。スクリーンショット生成開始。")
633
-
634
- # レンダリング前にドライバーの初期設定
635
- tmp_path = None
636
- try:
637
- # 一時ファイルにHTMLを保存
638
- with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode='w', encoding='utf-8') as tmp_file:
639
- tmp_path = tmp_file.name
640
- tmp_file.write(html_code)
641
- logger.info(f"HTMLを一時ファイルに保存: {tmp_path}")
642
-
643
- # ウィンドウサイズ初期設定
644
- initial_width = 1200
645
- initial_height = 1000
646
- driver.set_window_size(initial_width, initial_height)
647
- file_url = "file://" + tmp_path
648
- logger.info(f"ページに移動: {file_url}")
649
- driver.get(file_url)
650
-
651
- # ここからスクリーンショット生成ロジック(前の実装と同様)
652
- # ページ読み込み待機 - 動的な待機時間を実装
653
- logger.info("body要素を待機...")
654
- WebDriverWait(driver, 10).until(
655
- EC.presence_of_element_located((By.TAG_NAME, "body"))
656
- )
657
- logger.info("body要素を検出。リソース読み込みを待機...")
658
-
659
- # リソース読み込みの動的待機 - 最適化
660
- max_wait = 3 # 最大待機時間(秒)
661
- wait_increment = 0.2 # 確認間隔
662
- wait_time = 0
663
-
664
- while wait_time < max_wait:
665
- resource_state = driver.execute_script("""
666
- return {
667
- complete: document.readyState === 'complete',
668
- imgCount: document.images.length,
669
- imgLoaded: Array.from(document.images).filter(img => img.complete).length,
670
- faElements: document.querySelectorAll('.fa, .fas, .far, .fab, [class*="fa-"]').length
671
- };
672
- """)
673
-
674
- # ドキュメント完了かつ画像が読み込まれている場合、待機終了
675
- if resource_state['complete'] and (resource_state['imgCount'] == 0 or
676
- resource_state['imgLoaded'] == resource_state['imgCount']):
677
- logger.info(f"リソース読み込み完了: {resource_state}")
678
- break
679
-
680
- time.sleep(wait_increment)
681
- wait_time += wait_increment
682
-
683
- # Font Awesome要素が多い場合は追加待機
684
- fa_count = resource_state.get('faElements', 0)
685
- if fa_count > 30:
686
- logger.info(f"{fa_count}個のFont Awesome要素があるため、追加待機...")
687
- time.sleep(min(1.0, fa_count / 100)) # 要素数に応じて待機(最大0.5秒)
688
-
689
- # コンテンツレンダリングのための簡易スクロール
690
- driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
691
- time.sleep(0.2)
692
- driver.execute_script("window.scrollTo(0, 0);")
693
- time.sleep(0.2)
694
-
695
- # スクロールバーを非表示に
696
- driver.execute_script("""
697
- document.documentElement.style.overflow = 'hidden';
698
- document.body.style.overflow = 'hidden';
699
- """)
700
-
701
- # ページの寸法を取得
702
- dimensions = driver.execute_script("""
703
- return {
704
- width: Math.max(
705
- document.documentElement.scrollWidth,
706
- document.documentElement.offsetWidth,
707
- document.documentElement.clientWidth,
708
- document.body ? document.body.scrollWidth : 0,
709
- document.body ? document.body.offsetWidth : 0,
710
- document.body ? document.body.clientWidth : 0
711
- ),
712
- height: Math.max(
713
- document.documentElement.scrollHeight,
714
- document.documentElement.offsetHeight,
715
- document.documentElement.clientHeight,
716
- document.body ? document.body.scrollHeight : 0,
717
- document.body ? document.body.offsetHeight : 0,
718
- document.body ? document.body.clientHeight : 0
719
- )
720
- };
721
- """)
722
- scroll_width = dimensions['width']
723
- scroll_height = dimensions['height']
724
-
725
- # 最小/最大値の設定
726
- scroll_width = max(scroll_width, 100)
727
- scroll_height = max(scroll_height, 100)
728
- scroll_width = min(scroll_width, 2000)
729
- scroll_height = min(scroll_height, 4000)
730
-
731
- # 高さに余白を追加
732
- adjusted_height = int(scroll_height * (1 + extension_percentage / 100.0))
733
- adjusted_height = max(adjusted_height, scroll_height, 100)
734
-
735
- # ウィンドウサイズを調整
736
- driver.set_window_size(scroll_width, adjusted_height)
737
- time.sleep(0.2)
738
-
739
- # スクリーンショット取得
740
- logger.info("スクリーンショットを撮影...")
741
- png = driver.get_screenshot_as_png()
742
-
743
- # PIL画像に変換
744
- img = Image.open(BytesIO(png))
745
- logger.info(f"スクリーンショットサイズ: {img.width}x{img.height}")
746
-
747
- # 余白トリミング
748
- if trim_whitespace:
749
- img = trim_image_whitespace(img, threshold=248, padding=20)
750
- logger.info(f"トリミング後のサイズ: {img.width}x{img.height}")
751
-
752
- elapsed = time.time() - start_time
753
- logger.info(f"並列処理による生成完了。所要時間: {elapsed:.2f}秒")
754
- return img
755
-
756
- except Exception as e:
757
- logger.error(f"スクリーンショット生成中にエラー: {e}", exc_info=True)
758
- return Image.new('RGB', (1, 1), color=(0, 0, 0))
759
- finally:
760
- # WebDriverプールに戻す
761
- if driver_from_pool:
762
- driver_pool.release_driver(driver)
763
- # 一時ファイル削除
764
- if tmp_path and os.path.exists(tmp_path):
765
- try:
766
- os.remove(tmp_path)
767
- except Exception as e:
768
- logger.error(f"一時ファイル削除エラー: {e}")
769
-
770
- except Exception as e:
771
- logger.error(f"並列処理中のエラー: {e}", exc_info=True)
772
- return Image.new('RGB', (1, 1), color=(0, 0, 0)) # エラー時は黒画像
773
-
774
- # 従来の非並列版も残す(互換性のため)
775
- def text_to_screenshot(text: str, extension_percentage: float, temperature: float = 0.3,
776
- trim_whitespace: bool = True, style: str = "standard") -> Image.Image:
777
- """テキストをGemini APIでHTMLに変換し、スクリーンショットを生成する統合関数(レガシー版)"""
778
- # 並列処理版を呼び出す
779
- return text_to_screenshot_parallel(text, extension_percentage, temperature, trim_whitespace, style)
780
-
781
- # --- FastAPI Setup ---
782
  app = FastAPI()
783
-
784
- # CORS設定を追加
785
  app.add_middleware(
786
  CORSMiddleware,
787
- allow_origins=["*"],
788
- allow_credentials=True,
789
- allow_methods=["*"],
790
- allow_headers=["*"],
791
  )
792
 
793
- # 静的ファイルのサービング設定
794
- # Gradioのディレクトリを探索してアセットを見つける
795
- gradio_dir = os.path.dirname(gr.__file__)
796
- logger.info(f"Gradio version: {gr.__version__}")
797
- logger.info(f"Gradio directory: {gradio_dir}")
798
-
799
- # 基本的な静的ファイルディレクトリをマウント
800
- static_dir = os.path.join(gradio_dir, "templates", "frontend", "static")
801
- if os.path.exists(static_dir):
802
- logger.info(f"Mounting static directory: {static_dir}")
803
- app.mount("/static", StaticFiles(directory=static_dir), name="static")
804
-
805
- # _appディレクトリを探す(新しいSvelteKitベースのフロントエンド用)
806
- app_dir = os.path.join(gradio_dir, "templates", "frontend", "_app")
807
- if os.path.exists(app_dir):
808
- logger.info(f"Mounting _app directory: {app_dir}")
809
- app.mount("/_app", StaticFiles(directory=app_dir), name="_app")
810
-
811
- # assetsディレクトリを探す
812
- assets_dir = os.path.join(gradio_dir, "templates", "frontend", "assets")
813
- if os.path.exists(assets_dir):
814
- logger.info(f"Mounting assets directory: {assets_dir}")
815
- app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
816
-
817
- # cdnディレクトリがあれば追加
818
- cdn_dir = os.path.join(gradio_dir, "templates", "cdn")
819
- if os.path.exists(cdn_dir):
820
- logger.info(f"Mounting cdn directory: {cdn_dir}")
821
- app.mount("/cdn", StaticFiles(directory=cdn_dir), name="cdn")
822
-
823
-
824
- # API Endpoint for screenshot generation
825
- @app.post("/api/screenshot",
826
- response_class=StreamingResponse,
827
- tags=["Screenshot"],
828
- summary="Render HTML to Full Page Screenshot",
829
- description="Takes HTML code and an optional vertical extension percentage, renders it using a headless browser, and returns the full-page screenshot as a PNG image.")
830
- async def api_render_screenshot(request: ScreenshotRequest):
831
- """
832
- API endpoint to render HTML and return a screenshot.
833
- """
834
- try:
835
- logger.info(f"API request received. Extension: {request.extension_percentage}%")
836
- # Run the blocking Selenium code (now using the pooled version)
837
- pil_image = render_fullpage_screenshot(
838
- request.html_code,
839
- request.extension_percentage,
840
- request.trim_whitespace
841
- )
842
-
843
- if pil_image.size == (1, 1):
844
- logger.error("Screenshot generation failed, returning 1x1 error image.")
845
- # Optionally return a proper error response instead of 1x1 image
846
- # raise HTTPException(status_code=500, detail="Failed to generate screenshot")
847
-
848
- # Convert PIL Image to PNG bytes
849
- img_byte_arr = BytesIO()
850
- pil_image.save(img_byte_arr, format='PNG')
851
- img_byte_arr.seek(0) # Go to the start of the BytesIO buffer
852
-
853
- logger.info("Returning screenshot as PNG stream.")
854
- return StreamingResponse(img_byte_arr, media_type="image/png")
855
-
856
- except Exception as e:
857
- logger.error(f"API Error: {e}", exc_info=True)
858
- raise HTTPException(status_code=500, detail=f"Internal Server Error: {e}")
859
-
860
- # --- 新しいGemini API連携エンドポイント(並列処理版) ---
861
- @app.post("/api/text-to-screenshot",
862
- response_class=StreamingResponse,
863
- tags=["Screenshot", "Gemini"],
864
- summary="テキストからインフォグラフィックを生成",
865
- description="テキストをGemini APIを使ってHTMLインフォグラフィックに変換し、スクリーンショットとして返します。")
866
- async def api_text_to_screenshot(request: GeminiRequest):
867
- """
868
- テキストからHTMLインフォグラフィックを生成してスクリーンショットを返すAPIエンドポイント
869
- """
870
- try:
871
- logger.info(f"テキスト→スクリーンショットAPIリクエスト受信。テキスト長さ: {len(request.text)}, "
872
- f"拡張率: {request.extension_percentage}%, 温度: {request.temperature}, "
873
- f"スタイル: {request.style}")
874
-
875
- # 並列処理版を使用
876
- pil_image = text_to_screenshot_parallel(
877
- request.text,
878
- request.extension_percentage,
879
- request.temperature,
880
- request.trim_whitespace,
881
- request.style
882
- )
883
-
884
- if pil_image.size == (1, 1):
885
- logger.error("スクリーンショット生成に失敗しました。1x1エラー画像を返します。")
886
-
887
- # PIL画像をPNGバイトに変換
888
- img_byte_arr = BytesIO()
889
- pil_image.save(img_byte_arr, format='PNG')
890
- img_byte_arr.seek(0) # BytesIOバッファの先頭に戻る
891
-
892
- logger.info("スクリーンショットをPNGストリームとして返します。")
893
- return StreamingResponse(img_byte_arr, media_type="image/png")
894
-
895
- except Exception as e:
896
- logger.error(f"API Error: {e}", exc_info=True)
897
- raise HTTPException(status_code=500, detail=f"Internal Server Error: {e}")
898
-
899
- # --- Gradio Interface Definition ---
900
- # 入力モードの選択用Radioコンポーネント
901
- def process_input(input_mode, input_text, extension_percentage, temperature, trim_whitespace, style):
902
- """入力モードに応じて適切な処理を行う"""
903
- if input_mode == "HTML入力":
904
- # HTMLモードの場合は既存の処理(スタイルは使わない)
905
- return render_fullpage_screenshot(input_text, extension_percentage, trim_whitespace)
906
- else:
907
- # テキスト入力モードの場合はGemini APIを使用(並列処理版)
908
- return text_to_screenshot_parallel(input_text, extension_percentage, temperature, trim_whitespace, style)
909
-
910
- # Gradio UIの定義
911
- with gr.Blocks(title="Full Page Screenshot (テキスト変換対応)", theme=gr.themes.Base()) as iface:
912
- gr.Markdown("# HTMLビューア & テキスト→インフォグラフィック変換")
913
- gr.Markdown("HTMLコードをレンダリングするか、テキストをGemini APIでインフォグラフィックに変換して画像として取得します。")
914
- gr.Markdown("**パフォーマンス向上版**: 並列処理と最適化により処理時間を短縮しています")
915
-
916
  with gr.Row():
917
- input_mode = gr.Radio(
918
- ["HTML入力", "テキスト入力"],
919
- label="入力モード",
920
- value="HTML入力"
921
- )
922
-
923
- # 共用のテキストボックス
924
- input_text = gr.Textbox(
925
- lines=15,
926
- label="入力",
927
- placeholder="HTMLコードまたはテキストを入力してください。入力モードに応じて処理されます。"
928
- )
929
-
930
  with gr.Row():
931
- with gr.Column(scale=1):
932
- # スタイル選択ドロップダウン
933
- style_dropdown = gr.Dropdown(
934
- choices=["standard", "cute", "resort", "cool", "dental", "school", "KOKUGO"],
935
- value="standard",
936
- label="デザインスタイル",
937
- info="テキスト→HTML変換時のデザインテーマを選択します",
938
- visible=False # テキスト入力モードの時だけ表示
939
- )
940
-
941
  with gr.Column(scale=2):
942
- extension_percentage = gr.Slider(
943
- minimum=0,
944
- maximum=30,
945
- step=1.0,
946
- value=10, # デフォルト値10%
947
- label="上下高さ拡張率(%)"
948
- )
949
-
950
- # 温度調整スライダー(テキストモード時のみ表示)
951
- temperature = gr.Slider(
952
- minimum=0.0,
953
- maximum=1.0,
954
- step=0.1,
955
- value=0.5, # デフォルト値を0.5に設定
956
- label="生成時の温度(低い=一貫性高、高い=創造性高)",
957
- visible=False # 最初は非表示
958
- )
959
-
960
- # 余白トリミングオプション
961
- trim_whitespace = gr.Checkbox(
962
- label="余白を自動トリミング",
963
- value=True,
964
- info="生成される画像から余分な空白領域を自動的に削除します"
965
- )
966
-
967
  submit_btn = gr.Button("生成")
968
- output_image = gr.Image(type="pil", label="ページ全体のスクリーンショット")
969
-
970
- # 入力モード変更時のイベント処理(テキストモード時のみ温度スライダーとスタイルドロップダウンを表示)
971
- def update_controls_visibility(mode):
972
- # Gradio 4.x用のアップデート方法
973
- is_text_mode = mode == "テキスト入力"
974
- return [
975
- gr.update(visible=is_text_mode), # temperature
976
- gr.update(visible=is_text_mode), # style_dropdown
977
- ]
978
-
979
- input_mode.change(
980
- fn=update_controls_visibility,
981
- inputs=input_mode,
982
- outputs=[temperature, style_dropdown]
983
- )
984
 
985
- # 生成ボタンクリック時のイベント処理
986
- submit_btn.click(
987
- fn=process_input,
988
- inputs=[input_mode, input_text, extension_percentage, temperature, trim_whitespace, style_dropdown],
989
- outputs=output_image
990
- )
 
991
 
992
- # 環境変数情報を表示
993
  gemini_model = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
994
  gr.Markdown(f"""
995
- ## APIエンドポイント
996
- - `/api/screenshot` - HTMLコードからスクリーンショットを生成
997
- - `/api/text-to-screenshot` - テキストからインフォグラフィックスクリーンショットを生成
998
 
999
- ## 設定情報
1000
- - 使用モデル: {gemini_model} (環境変数 GEMINI_MODEL で変更可能)
1001
- - 対応スタイル: standard, cute, resort, cool, dental, school, KOKUGO
1002
- - WebDriverプール最大数: {driver_pool.max_drivers} (環境変数 MAX_WEBDRIVERS で変更可能)
1003
- """)
1004
 
1005
- # --- Mount Gradio App onto FastAPI ---
1006
- app = gr.mount_gradio_app(app, iface, path="/")
1007
 
1008
- # --- Run with Uvicorn (for local testing) ---
1009
  if __name__ == "__main__":
1010
  import uvicorn
1011
- logger.info("Starting Uvicorn server for local development...")
1012
  uvicorn.run(app, host="0.0.0.0", port=7860)
1013
 
1014
- # アプリケーション終了時にWebDriverプールをクリーンアップ
1015
  import atexit
1016
  atexit.register(driver_pool.close_all)
 
1
+ # app.py (Gradio 5.x 対応フルバージョン)
2
+ # =========================================
3
+ # 主要変更点
4
+ # • gradio==4.19.2 → gradio>=5.29.0 (requirements.txt で指定)
5
+ # • Blocks の theme を Origin() に変更(4.x の外観を継承)
6
+ # • mount_gradio_app に ssr_mode=False を追加
7
+ # • Gradio 内部アセットの手動マウントを撤去(5.x では不要)
8
+ # =========================================
9
+
10
+ import os
11
+ import time
12
+ import tempfile
13
+ import logging
14
+ import threading
15
+ import queue
16
+ from io import BytesIO
17
+ from concurrent.futures import ThreadPoolExecutor
18
+
19
+ import numpy as np # 画像トリミング高速化
20
+ from PIL import Image
21
+
22
+ import gradio as gr # ★ 5.x
23
+ from fastapi import FastAPI, HTTPException
24
  from fastapi.responses import StreamingResponse
 
25
  from fastapi.middleware.cors import CORSMiddleware
26
  from pydantic import BaseModel
27
+
28
  from selenium import webdriver
29
  from selenium.webdriver.chrome.options import Options
30
  from selenium.webdriver.common.by import By
31
  from selenium.webdriver.support.ui import WebDriverWait
32
  from selenium.webdriver.support import expected_conditions as EC
 
 
 
 
 
 
 
 
 
 
 
33
 
34
+ import google.generativeai as genai # Gemini
35
+ from huggingface_hub import hf_hub_download
36
 
37
+ # -------------------- ロガー設定 --------------------
38
  logging.basicConfig(level=logging.INFO)
39
  logger = logging.getLogger(__name__)
40
 
41
+ # ====================================================
42
+ # WebDriver プール実装
43
+ # ====================================================
44
  class WebDriverPool:
45
+ def __init__(self, max_drivers: int = 3):
 
46
  self.driver_queue = queue.Queue()
47
  self.max_drivers = max_drivers
48
  self.lock = threading.Lock()
49
  self.count = 0
50
+ logger.info(f"WebDriver プール初期化: 最大 {max_drivers}")
51
+
52
  def get_driver(self):
53
+ # 既存
54
  if not self.driver_queue.empty():
55
+ logger.info("既存 WebDriver を取得")
56
  return self.driver_queue.get()
57
+
58
+ # 新規
59
  with self.lock:
60
  if self.count < self.max_drivers:
61
  self.count += 1
62
+ logger.info(f"新規 WebDriver 作成 ({self.count}/{self.max_drivers})")
63
  options = Options()
64
  options.add_argument("--headless")
65
  options.add_argument("--no-sandbox")
 
67
  options.add_argument("--force-device-scale-factor=1")
68
  options.add_argument("--disable-features=NetworkService")
69
  options.add_argument("--dns-prefetch-disable")
70
+
71
+ driver_path = os.environ.get("CHROMEDRIVER_PATH")
72
+ if driver_path and os.path.exists(driver_path):
73
+ logger.info(f"環境変数 CHROMEDRIVER_PATH 使用: {driver_path}")
74
+ service = webdriver.ChromeService(executable_path=driver_path)
 
75
  return webdriver.Chrome(service=service, options=options)
76
+ return webdriver.Chrome(options=options)
77
+
78
+ # プール満杯
79
+ logger.info("プール満杯。返却待ち…")
 
 
80
  return self.driver_queue.get()
81
+
82
  def release_driver(self, driver):
 
83
  if driver:
84
  try:
 
85
  driver.get("about:blank")
86
  driver.execute_script("""
87
+ document.documentElement.style.overflow='';
88
+ document.body.style.overflow='';
89
  """)
90
  self.driver_queue.put(driver)
91
+ logger.info("WebDriver をプールに返却")
92
  except Exception as e:
93
+ logger.error(f"返却時エラー: {e}")
94
  driver.quit()
95
  with self.lock:
96
  self.count -= 1
97
+
98
  def close_all(self):
99
+ logger.info("全 WebDriver 終了処理")
 
100
  closed = 0
101
  while not self.driver_queue.empty():
102
  try:
 
106
  except queue.Empty:
107
  break
108
  except Exception as e:
109
+ logger.error(f"終了エラー: {e}")
 
 
110
  with self.lock:
111
  self.count = 0
112
+ logger.info(f"{closed} 個の WebDriver を終了")
113
 
114
+ # グローバルプール
 
115
  driver_pool = WebDriverPool(max_drivers=int(os.environ.get("MAX_WEBDRIVERS", "3")))
116
 
117
+ # ====================================================
118
+ # Pydantic モデル
119
+ # ====================================================
120
  class GeminiRequest(BaseModel):
 
121
  text: str
122
+ extension_percentage: float = 10.0
123
+ temperature: float = 0.5
124
+ trim_whitespace: bool = True
125
+ style: str = "standard"
126
 
127
  class ScreenshotRequest(BaseModel):
 
128
  html_code: str
129
+ extension_percentage: float = 10.0
130
+ trim_whitespace: bool = True
131
+ style: str = "standard"
132
+
133
+ # ====================================================
134
+ # 補助関数
135
+ # ====================================================
136
+ def enhance_font_awesome_layout(html_code: str) -> str:
137
+ """Font Awesome レイアウトを調整し preload タグを付与"""
138
  fa_preload = """
139
  <link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/webfonts/fa-solid-900.woff2" as="font" type="font/woff2" crossorigin>
140
  <link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/webfonts/fa-regular-400.woff2" as="font" type="font/woff2" crossorigin>
141
  <link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/webfonts/fa-brands-400.woff2" as="font" type="font/woff2" crossorigin>
142
  """
143
+ fa_css = """
 
 
144
  <style>
145
+ [class*="fa-"]{display:inline-block!important;margin-right:8px!important;vertical-align:middle!important;}
146
+ h1 [class*="fa-"],h2 [class*="fa-"],h3 [class*="fa-"],h4 [class*="fa-"],h5 [class*="fa-"],h6 [class*="fa-"]{vertical-align:middle!important;margin-right:10px!important;}
147
+ .fa+span,.fas+span,.far+span,.fab+span,span+.fa,span+.fas,span+.far+span{display:inline-block!important;margin-left:5px!important;}
148
+ .card [class*="fa-"],.card-body [class*="fa-"]{float:none!important;clear:none!important;position:relative!important;}
149
+ li [class*="fa-"],p [class*="fa-"]{margin-right:10px!important;}
150
+ .inline-icon{display:inline-flex!important;align-items:center!important;justify-content:flex-start!important;}
151
+ [class*="fa-"]+span{display:inline-block!important;vertical-align:middle!important;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  </style>
153
  """
 
 
154
  if '<head>' in html_code:
155
+ return html_code.replace('</head>', f'{fa_preload}{fa_css}</head>')
 
156
  elif '<html' in html_code:
157
  head_end = html_code.find('</head>')
158
  if head_end > 0:
159
+ return html_code[:head_end] + fa_preload + fa_css + html_code[head_end:]
160
+ body_start = html_code.find('<body')
161
+ if body_start > 0:
162
+ return html_code[:body_start] + f'<head>{fa_preload}{fa_css}</head>' + html_code[body_start:]
163
+ return f'<html><head>{fa_preload}{fa_css}</head>{html_code}</html>'
164
+
165
+ def load_system_instruction(style: str = "standard") -> str:
166
+ """テーマ別 prompt.txt を読み込み"""
167
+ valid = ["standard", "cute", "resort", "cool", "dental", "school", "KOKUGO"]
168
+ if style not in valid:
169
+ logger.warning(f"無効 style '{style}' → 'standard'")
170
+ style = "standard"
171
+
172
+ local_path = os.path.join(os.path.dirname(__file__), style, "prompt.txt")
173
+ if os.path.exists(local_path):
174
+ with open(local_path, encoding="utf-8") as f:
175
+ return f.read()
176
 
 
 
 
 
 
 
 
 
 
 
177
  try:
178
+ file_path = hf_hub_download(
179
+ repo_id="tomo2chin2/GURAREKOstlyle",
180
+ filename=f"{style}/prompt.txt",
181
+ repo_type="dataset"
182
+ )
183
+ with open(file_path, encoding="utf-8") as f:
184
+ return f.read()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  except Exception as e:
186
+ logger.warning(f"HuggingFace 取得失敗 ({e}) → デフォルト prompt.txt")
187
+ file_path = hf_hub_download(
188
+ repo_id="tomo2chin2/GURAREKOstlyle",
189
+ filename="prompt.txt",
190
+ repo_type="dataset"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  )
192
+ with open(file_path, encoding="utf-8") as f:
193
+ return f.read()
194
 
195
+ def generate_html_from_text(text: str, temperature: float = 0.5, style: str = "standard") -> str:
196
+ """Gemini HTML 生成"""
197
+ api_key = os.environ.get("GEMINI_API_KEY")
198
+ if not api_key:
199
+ raise ValueError("環境変数 GEMINI_API_KEY が未設定")
 
 
 
 
 
 
 
 
 
 
200
 
201
+ model_name = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
202
+ genai.configure(api_key=api_key)
 
 
 
203
 
204
+ system_instruction = load_system_instruction(style)
205
+ model = genai.GenerativeModel(model_name)
 
206
 
207
+ generation_config = dict(
208
+ temperature=temperature, top_p=0.7, top_k=20, max_output_tokens=8192,
209
+ candidate_count=1
210
+ )
211
+ safety_settings = [
212
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
213
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
214
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
215
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}
216
+ ]
217
+ prompt = f"{system_instruction}\n\n{text}"
218
+ response = model.generate_content(prompt, generation_config=generation_config, safety_settings=safety_settings)
219
+ raw = response.text
220
+
221
+ html_start = raw.find("```html")
222
+ html_end = raw.rfind("```")
223
+ if html_start != -1 and html_end != -1 and html_start < html_end:
224
+ html_code = raw[html_start + 7:html_end].strip()
225
+ else:
226
+ html_code = raw
227
+
228
+ html_code = enhance_font_awesome_layout(html_code)
229
+ return html_code
230
+
231
+ def trim_image_whitespace(image: Image.Image, threshold: int = 250, padding: int = 10) -> Image.Image:
232
+ """白余白トリミング(NumPy 高速化)"""
233
+ gray = image.convert("L")
234
+ arr = np.array(gray)
235
+ mask = arr < threshold
236
+ rows = np.any(mask, axis=1)
237
+ cols = np.any(mask, axis=0)
238
+ if np.any(rows) and np.any(cols):
239
+ y_min, y_max = np.where(rows)[0][[0, -1]]
240
+ x_min, x_max = np.where(cols)[0][[0, -1]]
241
+ y_min = max(0, y_min - padding)
242
+ x_min = max(0, x_min - padding)
243
+ y_max = min(image.height - 1, y_max + padding)
244
+ x_max = min(image.width - 1, x_max + padding)
245
+ return image.crop((x_min, y_min, x_max + 1, y_max + 1))
246
+ return image
247
+
248
+ # ----------------------------------------------------
249
+ # HTML → スクリーンショット
250
+ # ----------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
251
  def render_fullpage_screenshot(html_code: str, extension_percentage: float = 6.0,
252
+ trim_whitespace: bool = True,
253
+ driver=None) -> Image.Image:
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  driver_from_pool = False
255
+ tmp_path = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  try:
257
+ if driver is None:
258
+ driver = driver_pool.get_driver()
259
+ driver_from_pool = True
260
+
261
+ with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode="w", encoding="utf-8") as tmp:
262
+ tmp_path = tmp.name
263
+ tmp.write(html_code)
264
+
265
+ initial_w, initial_h = 1200, 1000
266
+ driver.set_window_size(initial_w, initial_h)
267
+ driver.get(f"file://{tmp_path}")
268
+
269
+ WebDriverWait(driver, 10).until(EC.presence_of_element_located((By.TAG_NAME, "body")))
270
+
271
+ total_h = driver.execute_script("return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)")
272
+ viewport_h = driver.execute_script("return window.innerHeight")
273
+ scrolls = max(1, min(5, total_h // viewport_h))
274
+ for i in range(scrolls):
275
+ driver.execute_script(f"window.scrollTo(0, {i * (viewport_h - 100)})")
276
+ time.sleep(0.1)
277
+ driver.execute_script("window.scrollTo(0, 0)")
278
+ time.sleep(0.2)
279
+
280
+ dims = driver.execute_script("""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  return {
282
+ width: Math.max(document.body.scrollWidth, document.documentElement.scrollWidth),
283
+ height: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)
284
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  """)
286
+ w = min(max(dims["width"], 100), 2000)
287
+ h = min(max(dims["height"], 100), 4000)
288
+ h = int(h * (1 + extension_percentage / 100.0))
289
+ driver.set_window_size(w, h)
290
+ time.sleep(0.5)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
+ png = driver.get_screenshot_as_png()
293
  img = Image.open(BytesIO(png))
 
 
 
294
  if trim_whitespace:
295
  img = trim_image_whitespace(img, threshold=248, padding=20)
 
 
296
  return img
 
297
  except Exception as e:
298
+ logger.error(f"Screenshot error: {e}", exc_info=True)
299
+ return Image.new("RGB", (1, 1), (0, 0, 0))
 
300
  finally:
 
 
301
  if driver_from_pool:
302
  driver_pool.release_driver(driver)
 
 
303
  if tmp_path and os.path.exists(tmp_path):
304
  try:
305
  os.remove(tmp_path)
306
+ except Exception:
307
+ pass
 
308
 
309
+ # ----------------------------------------------------
310
+ # テキスト → スクリーンショット(並列)
311
+ # ----------------------------------------------------
312
  def text_to_screenshot_parallel(text: str, extension_percentage: float, temperature: float = 0.5,
313
+ trim_whitespace: bool = True, style: str = "standard") -> Image.Image:
314
+ start = time.time()
315
+ with ThreadPoolExecutor(max_workers=2) as exe:
316
+ html_fut = exe.submit(generate_html_from_text, text, temperature, style)
317
+ driver_fut = exe.submit(driver_pool.get_driver)
318
+
319
+ html_code = html_fut.result()
320
+ driver = driver_fut.result()
321
+ img = render_fullpage_screenshot(html_code, extension_percentage, trim_whitespace, driver)
322
+ logger.info(f"並列処理 完了 {time.time() - start:.2f}s")
323
+ return img
324
+
325
+ def text_to_screenshot(*args, **kwargs) -> Image.Image:
326
+ return text_to_screenshot_parallel(*args, **kwargs)
327
+
328
+ # ====================================================
329
+ # FastAPI セットアップ
330
+ # ====================================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  app = FastAPI()
 
 
332
  app.add_middleware(
333
  CORSMiddleware,
334
+ allow_origins=["*"], allow_credentials=True,
335
+ allow_methods=["*"], allow_headers=["*"]
 
 
336
  )
337
 
338
+ # ------------ API エンドポイント ---------------
339
+ @app.post("/api/screenshot", response_class=StreamingResponse, tags=["Screenshot"])
340
+ async def api_render_screenshot(req: ScreenshotRequest):
341
+ img = render_fullpage_screenshot(req.html_code, req.extension_percentage, req.trim_whitespace)
342
+ buf = BytesIO()
343
+ img.save(buf, format="PNG"); buf.seek(0)
344
+ return StreamingResponse(buf, media_type="image/png")
345
+
346
+ @app.post("/api/text-to-screenshot", response_class=StreamingResponse, tags=["Screenshot", "Gemini"])
347
+ async def api_text_to_screenshot(req: GeminiRequest):
348
+ img = text_to_screenshot_parallel(req.text, req.extension_percentage,
349
+ req.temperature, req.trim_whitespace, req.style)
350
+ buf = BytesIO()
351
+ img.save(buf, format="PNG"); buf.seek(0)
352
+ return StreamingResponse(buf, media_type="image/png")
353
+
354
+ # ====================================================
355
+ # Gradio UI
356
+ # ====================================================
357
+ def process_input(mode, input_text, ext_perc, temp, trim_ws, style):
358
+ if mode == "HTML入力":
359
+ return render_fullpage_screenshot(input_text, ext_perc, trim_ws)
360
+ return text_to_screenshot_parallel(input_text, ext_perc, temp, trim_ws, style)
361
+
362
+ with gr.Blocks(title="Full Page Screenshot (テキスト変換対応)",
363
+ theme=gr.themes.Origin()) as iface:
364
+ gr.Markdown("# HTMLビューア & テキスト→インフォグラフィック")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  with gr.Row():
366
+ input_mode = gr.Radio(["HTML入力", "テキスト入力"], value="HTML入力", label="入力モード")
367
+ input_text = gr.Textbox(lines=15, label="入力")
 
 
 
 
 
 
 
 
 
 
 
368
  with gr.Row():
369
+ style_dropdown = gr.Dropdown(["standard", "cute", "resort", "cool", "dental", "school", "KOKUGO"],
370
+ value="standard", label="デザインスタイル", visible=False)
 
 
 
 
 
 
 
 
371
  with gr.Column(scale=2):
372
+ ext_slider = gr.Slider(0, 30, value=10, step=1, label="上下高さ拡張率(%)")
373
+ temp_slider = gr.Slider(0.0, 1.0, value=0.5, step=0.1,
374
+ label="生成時の温度", visible=False)
375
+ trim_ws_chk = gr.Checkbox(value=True, label="余白を自動トリミング")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
376
  submit_btn = gr.Button("生成")
377
+ out_img = gr.Image(type="pil", label="スクリーンショット")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
378
 
379
+ def toggle_controls(mode):
380
+ is_text = mode == "テキスト入力"
381
+ return [gr.update(visible=is_text), gr.update(visible=is_text)]
382
+ input_mode.change(toggle_controls, input_mode, [temp_slider, style_dropdown])
383
+ submit_btn.click(process_input,
384
+ [input_mode, input_text, ext_slider, temp_slider, trim_ws_chk, style_dropdown],
385
+ out_img)
386
 
 
387
  gemini_model = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
388
  gr.Markdown(f"""
389
+ **API**
390
+ - `/api/screenshot` HTML → PNG
391
+ - `/api/text-to-screenshot` テキスト → インフォグラフィック PNG
392
 
393
+ **設定**
394
+ - 使用モデル: `{gemini_model}`
395
+ - スタイル: standard / cute / resort / cool / dental / school / KOKUGO
396
+ - WebDriver 最大数: {driver_pool.max_drivers}
397
+ """)
398
 
399
+ # FastAPI へマウント(SSR 無効)
400
+ app = gr.mount_gradio_app(app, iface, path="/", ssr_mode=False)
401
 
402
+ # ローカル実行用
403
  if __name__ == "__main__":
404
  import uvicorn
 
405
  uvicorn.run(app, host="0.0.0.0", port=7860)
406
 
407
+ # 終了時のクリーンアップ
408
  import atexit
409
  atexit.register(driver_pool.close_all)