tomo2chin2 commited on
Commit
aeb0d3b
·
verified ·
1 Parent(s): 7215bb7

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +979 -340
app.py CHANGED
@@ -1,4 +1,3 @@
1
- # -*- coding: utf-8 -*-
2
  import gradio as gr
3
  from fastapi import FastAPI, HTTPException, Body
4
  from fastapi.responses import StreamingResponse
@@ -18,10 +17,9 @@ import os
18
  import logging
19
  from huggingface_hub import hf_hub_download
20
 
21
- # 正しいGemini関連のインポート
22
  import google.generativeai as genai
23
- # 追加: thinking_config を使用するために types をインポート
24
- from google.generativeai import types
25
 
26
  # ロギング設定
27
  logging.basicConfig(level=logging.INFO)
@@ -31,21 +29,22 @@ logger = logging.getLogger(__name__)
31
  class GeminiRequest(BaseModel):
32
  """Geminiへのリクエストデータモデル"""
33
  text: str
34
- extension_percentage: float = 10.0
35
- temperature: float = 0.5
36
- trim_whitespace: bool = True
37
- style: str = "standard"
38
 
39
  class ScreenshotRequest(BaseModel):
40
  """スクリーンショットリクエストモデル"""
41
  html_code: str
42
- extension_percentage: float = 10.0
43
- trim_whitespace: bool = True
44
- style: str = "standard" # スタイルは実際には使われないが、整合性のため残す
45
 
46
  # HTMLのFont Awesomeレイアウトを改善する関数
47
  def enhance_font_awesome_layout(html_code):
48
  """Font Awesomeレイアウトを改善するCSSを追加"""
 
49
  fa_fix_css = """
50
  <style>
51
  /* Font Awesomeアイコンのレイアウト修正 */
@@ -54,37 +53,52 @@ def enhance_font_awesome_layout(html_code):
54
  margin-right: 8px !important;
55
  vertical-align: middle !important;
56
  }
 
 
57
  h1 [class*="fa-"], h2 [class*="fa-"], h3 [class*="fa-"],
58
  h4 [class*="fa-"], h5 [class*="fa-"], h6 [class*="fa-"] {
59
  vertical-align: middle !important;
60
  margin-right: 10px !important;
61
  }
 
 
62
  .fa + span, .fas + span, .far + span, .fab + span,
63
- span + .fa, span + .fas, span + .far, span + .fab { /* 修正: span + .fab が抜けていたのを追加 */
64
  display: inline-block !important;
65
  margin-left: 5px !important;
66
  }
 
 
67
  .card [class*="fa-"], .card-body [class*="fa-"] {
68
  float: none !important;
69
  clear: none !important;
70
  position: relative !important;
71
  }
 
 
72
  li [class*="fa-"], p [class*="fa-"] {
73
  margin-right: 10px !important;
74
  }
 
 
75
  .inline-icon {
76
  display: inline-flex !important;
77
  align-items: center !important;
78
  justify-content: flex-start !important;
79
  }
 
 
80
  [class*="fa-"] + span {
81
  display: inline-block !important;
82
  vertical-align: middle !important;
83
  }
84
  </style>
85
  """
 
 
86
  if '<head>' in html_code:
87
  return html_code.replace('</head>', f'{fa_fix_css}</head>')
 
88
  elif '<html' in html_code:
89
  head_end = html_code.find('</head>')
90
  if head_end > 0:
@@ -93,351 +107,453 @@ def enhance_font_awesome_layout(html_code):
93
  body_start = html_code.find('<body')
94
  if body_start > 0:
95
  return html_code[:body_start] + f'<head>{fa_fix_css}</head>' + html_code[body_start:]
96
- return f'<html><head>{fa_fix_css}</head><body>{html_code}</body></html>' # ボディタグも追加
97
 
 
 
98
 
99
  def load_system_instruction(style="standard"):
100
- """指定されたスタイルのシステムインストラクションを読み込む"""
 
 
 
 
 
 
 
 
101
  try:
 
102
  valid_styles = ["standard", "cute", "resort", "cool", "dental"]
 
 
103
  if style not in valid_styles:
104
- logger.warning(f"無効な���タイル '{style}'。デフォルトの 'standard' を使用します。")
105
  style = "standard"
 
106
  logger.info(f"スタイル '{style}' のシステムインストラクションを読み込みます")
107
 
108
- # ローカルパス優先
109
- try:
110
- # スクリプトのディレクトリ基準に変更
111
- script_dir = os.path.dirname(os.path.abspath(__file__))
112
- local_path = os.path.join(script_dir, style, "prompt.txt")
113
- if os.path.exists(local_path):
114
- logger.info(f"ローカルファイルを使用: {local_path}")
115
- with open(local_path, 'r', encoding='utf-8') as file:
116
- return file.read()
117
- except NameError: # __file__ が定義されていない場合(例:インタラクティブ実行)
118
- logger.warning("__file__ が未定義のため、ローカルパス検索をスキップします。")
119
-
120
-
121
- # HuggingFaceからのダウンロード
122
  try:
 
123
  file_path = hf_hub_download(
124
  repo_id="tomo2chin2/GURAREKOstlyle",
125
  filename=f"{style}/prompt.txt",
126
  repo_type="dataset"
127
  )
128
- logger.info(f"スタイル '{style}' をHuggingFaceから読み込み: {file_path}")
 
129
  with open(file_path, 'r', encoding='utf-8') as file:
130
- return file.read()
 
 
131
  except Exception as style_error:
132
- logger.warning(f"スタイル '{style}' 読み込み失敗: {style_error}。デフォルトを試みます。")
 
 
 
133
  file_path = hf_hub_download(
134
  repo_id="tomo2chin2/GURAREKOstlyle",
135
  filename="prompt.txt",
136
  repo_type="dataset"
137
  )
 
138
  with open(file_path, 'r', encoding='utf-8') as file:
139
  instruction = file.read()
 
140
  logger.info("デフォルトのシステムインストラクションを読み込みました")
141
  return instruction
 
142
  except Exception as e:
143
- error_msg = f"システムインストラクション読み込み失敗: {e}"
144
  logger.error(error_msg)
145
  raise ValueError(error_msg)
146
 
147
- # ★★★★★ generate_html_from_text 関数の修正箇所 ★★★★★
148
  def generate_html_from_text(text, temperature=0.3, style="standard"):
149
- """テキストからHTMLを生成する(thinking_budget対応)"""
150
  try:
 
151
  api_key = os.environ.get("GEMINI_API_KEY")
152
  if not api_key:
153
  logger.error("GEMINI_API_KEY 環境変数が設定されていません")
154
  raise ValueError("GEMINI_API_KEY 環境変数が設定されていません")
155
 
156
- # ★ モデル名は環境変数から取得(デフォルトは変更なし)
 
157
  model_name = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
158
  logger.info(f"使用するGeminiモデル: {model_name}")
159
 
 
160
  genai.configure(api_key=api_key)
 
 
161
  system_instruction = load_system_instruction(style)
162
- model = genai.GenerativeModel(model_name)
163
 
164
- logger.info(f"Gemini APIリクエスト: テキスト長={len(text)}, 温度={temperature}, スタイル={style}")
 
165
 
166
- # GenerationConfig のパラメータを辞書で準備
167
- generation_config_params = {
168
- "temperature": temperature,
 
169
  "top_p": 0.7,
170
  "top_k": 20,
171
  "max_output_tokens": 8192,
172
- "candidate_count": 1
173
  }
174
 
175
- # モデル名が 'gemini-2.5-flash-preview-04-17' の場合のみ thinking_config を追加
176
  if model_name == "gemini-2.5-flash-preview-04-17":
177
- logger.info(f"モデル {model_name} のため、thinking_config(thinking_budget=0) を設定します。")
178
- # types.ThinkingConfig を使用して thinking_budget を設定
179
- generation_config_params["thinking_config"] = types.ThinkingConfig(thinking_budget=0)
180
- else:
181
- logger.info(f"モデル {model_name} のため、thinking_config は設定しません。")
182
 
183
- # ★ 辞書から GenerationConfig オブジェクトを作成
184
- generation_config = types.GenerationConfig(**generation_config_params)
185
 
186
- # 安全設定 (変更なし)
187
- safety_settings = [
188
- {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
189
- {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
190
- {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"},
191
- {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}
192
- ]
193
-
194
- prompt = f"{system_instruction}\n\n{text}"
195
 
196
- # ★ 修正した generation_config を使用してコンテンツ生成
 
197
  response = model.generate_content(
198
- prompt,
199
- generation_config=generation_config,
200
  safety_settings=safety_settings
201
  )
202
 
 
203
  raw_response = response.text
204
- # HTMLタグ抽出処理を改善 (```html ... ``` を想定)
 
205
  html_start = raw_response.find("```html")
206
  html_end = raw_response.rfind("```")
207
 
208
  if html_start != -1 and html_end != -1 and html_start < html_end:
209
- html_start += 7 # "```html" の長さ分進める
210
  html_code = raw_response[html_start:html_end].strip()
211
- logger.info(f"HTML生成成功: 長さ={len(html_code)}")
 
 
212
  html_code = enhance_font_awesome_layout(html_code)
213
- logger.info("Font Awesomeレイアウト最適化適用")
 
214
  return html_code
215
  else:
216
- logger.warning("レスポンスから ```html ``` タグが見つかりません。全テキストを返します。")
217
- # フォールバックとして、もしHTMLタグが直接含まれていればそれを抽出
218
- html_start_alt = raw_response.find("<html")
219
- html_end_alt = raw_response.rfind("</html>")
220
- if html_start_alt != -1 and html_end_alt != -1 and html_start_alt < html_end_alt:
221
- html_code = raw_response[html_start_alt:html_end_alt + 7].strip() # </html>を含む
222
- logger.info(f"代替HTML抽出成功: 長さ={len(html_code)}")
223
- html_code = enhance_font_awesome_layout(html_code)
224
- return html_code
225
- return raw_response # それでも見つからない場合は生レスポンス
226
 
227
  except Exception as e:
228
  logger.error(f"HTML生成中にエラーが発生: {e}", exc_info=True)
229
- # エラーの詳細を返すように変更
230
- raise Exception(f"Gemini APIでのHTML生成に失敗しました: {type(e).__name__} - {e}")
231
- # ★★★★★ 修正箇所ここまで ★★★★★
232
 
233
 
234
- # 画像から余分な空白領域をトリミングする関数 (変更なし)
235
  def trim_image_whitespace(image, threshold=250, padding=10):
 
 
 
 
 
 
 
 
 
 
 
 
236
  gray = image.convert('L')
 
 
237
  data = gray.getdata()
238
  width, height = gray.size
 
 
239
  min_x, min_y = width, height
240
  max_x = max_y = 0
 
 
241
  pixels = list(data)
242
  pixels = [pixels[i * width:(i + 1) * width] for i in range(height)]
 
 
243
  for y in range(height):
244
  for x in range(width):
245
- if pixels[y][x] < threshold:
246
  min_x = min(min_x, x)
247
  min_y = min(min_y, y)
248
  max_x = max(max_x, x)
249
  max_y = max(max_y, y)
 
250
  if min_x > max_x or min_y > max_y:
251
  logger.warning("トリミング領域が見つかりません。元の画像を返します。")
252
  return image
 
 
253
  min_x = max(0, min_x - padding)
254
  min_y = max(0, min_y - padding)
255
  max_x = min(width - 1, max_x + padding)
256
  max_y = min(height - 1, max_y + padding)
 
 
257
  trimmed = image.crop((min_x, min_y, max_x + 1, max_y + 1))
258
- logger.info(f"画像トリミング: 元{width}x{height} → 新{trimmed.width}x{trimmed.height}")
 
259
  return trimmed
260
 
261
- # render_fullpage_screenshot 関数 (変更なし)
 
262
  def render_fullpage_screenshot(html_code: str, extension_percentage: float = 6.0,
263
  trim_whitespace: bool = True) -> Image.Image:
 
 
 
 
 
 
 
 
 
 
 
264
  tmp_path = None
265
  driver = None
 
 
266
  try:
267
  with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode='w', encoding='utf-8') as tmp_file:
268
  tmp_path = tmp_file.name
269
  tmp_file.write(html_code)
270
- logger.info(f"HTMLを一時ファイルに保存: {tmp_path}")
271
-
272
- options = Options()
273
- options.add_argument("--headless")
274
- options.add_argument("--no-sandbox")
275
- options.add_argument("--disable-dev-shm-usage")
276
- options.add_argument("--force-device-scale-factor=1")
277
- # options.add_argument("--disable-gpu") # headlessでは不要な場合が多い
278
- options.add_argument("--window-size=1200,1000") # 初期サイズ指定
279
- options.add_argument("--hide-scrollbars") # スクロールバー非表示化
280
- # Add log level setting for Chrome
281
- options.add_argument("--log-level=0") # Suppress most logs
282
- options.add_experimental_option('excludeSwitches', ['enable-logging']) # Disable default logging
283
-
284
-
285
- # 環境変数からWebDriverパスを取得
286
- webdriver_path = os.environ.get("CHROMEDRIVER_PATH")
287
- service = None
288
- if webdriver_path and os.path.exists(webdriver_path):
289
- logger.info(f"指定されたWebDriverを使用: {webdriver_path}")
290
- service = webdriver.ChromeService(executable_path=webdriver_path)
291
- else:
292
- logger.info("WebDriverをPATHから検索します。")
 
 
293
 
294
- logger.info("WebDriverを初期化中...")
 
295
  if service:
296
- driver = webdriver.Chrome(service=service, options=options)
297
  else:
298
- driver = webdriver.Chrome(options=options) # PATHから探す
299
- logger.info("WebDriver初期化完了。")
300
 
 
 
 
 
301
  file_url = "file://" + tmp_path
302
- logger.info(f"ページに移動: {file_url}")
303
  driver.get(file_url)
304
 
305
- logger.info("body要素の待機...")
306
- WebDriverWait(driver, 20).until( # タイムアウトを少し延長
 
307
  EC.presence_of_element_located((By.TAG_NAME, "body"))
308
  )
309
- logger.info("body要素発見。リソース読み込み待機...")
310
- time.sleep(3) # 基本待機
311
-
312
- # Font Awesome要素数をチェック
313
- fa_count = driver.execute_script("return document.querySelectorAll('[class*=\"fa-\"]').length;")
314
- logger.info(f"Font Awesome要素数: {fa_count}")
315
- if fa_count > 30: # しきい値を調整
316
- logger.info(f"{fa_count}個のFAアイコン検出、追加待機...")
317
- time.sleep(max(2, min(5, fa_count // 15))) # 要素数に応じて待機時間を調整
318
-
319
- logger.info("コンテンツレンダリングのためスクロール実行...")
320
- # スクロール処理を簡略化・安定化
321
- last_height = driver.execute_script("return document.body.scrollHeight")
322
- while True:
323
- driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
324
- time.sleep(0.5) # スクロール後の待機
325
- new_height = driver.execute_script("return document.body.scrollHeight")
326
- if new_height == last_height:
327
- break
328
- last_height = new_height
329
- driver.execute_script("window.scrollTo(0, 0);") # トップに戻る
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  time.sleep(0.5)
331
- logger.info("スクロールレンダリング完了")
332
 
333
- # 7) スクロールバーを非表示に (再度実行)
334
- driver.execute_script("document.documentElement.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';")
335
- logger.info("スクロールバー非表示")
 
 
 
336
 
 
337
  dimensions = driver.execute_script("""
338
  return {
339
- width: Math.max(document.body.scrollWidth, document.documentElement.scrollWidth, document.body.offsetWidth, document.documentElement.offsetWidth, document.body.clientWidth, document.documentElement.clientWidth),
340
- height: Math.max(document.body.scrollHeight, document.documentElement.scrollHeight, document.body.offsetHeight, document.documentElement.offsetHeight, document.body.clientHeight, document.documentElement.clientHeight)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  };
342
  """)
343
  scroll_width = dimensions['width']
344
  scroll_height = dimensions['height']
345
- logger.info(f"検出された寸法: 幅={scroll_width}, 高さ={scroll_height}")
346
 
347
- # 寸法の下限・上限を設定
348
- scroll_width = max(scroll_width, 300) # 最小幅
349
- scroll_height = max(scroll_height, 200) # 最小高さ
350
- scroll_width = min(scroll_width, 2500) # 最大幅
351
- scroll_height = min(scroll_height, 8000) # 最大高さ (必要に応じて調整)
 
 
 
 
 
 
 
 
 
 
352
 
353
- logger.info("レイアウト安定化待機...")
354
- time.sleep(1.5) # 安定化のための待機
 
355
 
 
356
  adjusted_height = int(scroll_height * (1 + extension_percentage / 100.0))
357
- adjusted_height = max(adjusted_height, scroll_height + 50) # 最小でも元の高さ+50pxは確保
358
- logger.info(f"調整後の高さ計算: {adjusted_height} (拡張率: {extension_percentage}%)")
359
 
 
360
  adjusted_width = scroll_width
361
- logger.info(f"ウィンドウサイズ変更: 幅={adjusted_width}, 高さ={adjusted_height}")
362
- # サイズ変更前に現在のサイズを取得
363
- current_size = driver.get_window_size()
364
- if current_size['width'] != adjusted_width or current_size['height'] != adjusted_height:
365
- driver.set_window_size(adjusted_width, adjusted_height)
366
- time.sleep(1) # サイズ変更後の待機
367
- else:
368
- logger.info("ウィンドウサイズ変��不要")
369
-
370
 
371
- # 最終的なリソース状態確認
372
- readyState = driver.execute_script("return document.readyState")
373
- logger.info(f"最終的な document.readyState: {readyState}")
374
- if readyState != 'complete':
375
- logger.warning("ドキュメントがまだ 'complete' ではありません。スクリーンショットは不完全かもしれません。")
376
- time.sleep(1)
 
 
 
377
 
 
 
 
378
 
379
- logger.info("スクリーンショット取得中...")
380
- # body要素を指定してスクリーンショットを取得(より正確な場合がある)
381
- try:
382
- body_element = driver.find_element(By.TAG_NAME, 'body')
383
- png = body_element.screenshot_as_png
384
- logger.info("Body要素のスクリーンショット取得成功。")
385
- except Exception as body_e:
386
- logger.warning(f"Body要素のスクリーンショット取得失敗 ({body_e})。ページ全体のスクリーンショットを試みます。")
387
- png = driver.get_screenshot_as_png() # フォールバック
388
- logger.info("スクリーンショット取得完了。")
389
 
 
 
 
 
390
 
 
391
  img = Image.open(BytesIO(png))
392
- logger.info(f"スクリーンショット寸法: {img.width}x{img.height}")
393
 
 
394
  if trim_whitespace:
395
  img = trim_image_whitespace(img, threshold=248, padding=20)
396
- logger.info(f"トリミング後の寸法: {img.width}x{img.height}")
397
 
398
  return img
399
 
400
  except Exception as e:
401
- logger.error(f"スクリーンショット生成中にエラー発生: {e}", exc_info=True)
402
- # エラー時に情報を含む小さな画像や、デフォルトのエラー画像を返す
403
- error_img = Image.new('RGB', (200, 50), color = (255, 200, 200)) # 薄い赤背景
404
- # ここでエラーメッセージを描画することも可能(PillowのImageDrawを使用)
405
- # from PIL import ImageDraw
406
- # draw = ImageDraw.Draw(error_img)
407
- # draw.text((10, 10), f"Error: {type(e).__name__}", fill=(0,0,0))
408
- return error_img # エラーを示す画像を返す
409
  finally:
410
- logger.info("クリーンアップ処理...")
411
  if driver:
412
  try:
413
  driver.quit()
414
- logger.info("WebDriver終了成功。")
415
  except Exception as e:
416
- logger.error(f"WebDriver終了中にエラー: {e}", exc_info=True)
417
  if tmp_path and os.path.exists(tmp_path):
418
  try:
419
  os.remove(tmp_path)
420
- logger.info(f"一時ファイル削除: {tmp_path}")
421
  except Exception as e:
422
- logger.error(f"一時ファイル削除中にエラー {tmp_path}: {e}", exc_info=True)
423
 
424
 
425
- # --- Geminiを使った新しい関数 --- (変更なし)
426
  def text_to_screenshot(text: str, extension_percentage: float, temperature: float = 0.3,
427
  trim_whitespace: bool = True, style: str = "standard") -> Image.Image:
428
  """テキストをGemini APIでHTMLに変換し、スクリーンショットを生成する統合関数"""
429
  try:
 
430
  html_code = generate_html_from_text(text, temperature, style)
 
 
431
  return render_fullpage_screenshot(html_code, extension_percentage, trim_whitespace)
432
  except Exception as e:
433
- logger.error(f"テキストからスクリーンショット生成中にエラー発生: {e}", exc_info=True)
434
- # エラーを示す画像を返す
435
- error_img = Image.new('RGB', (200, 50), color = (255, 200, 200))
436
- return error_img
437
 
438
- # --- FastAPI Setup --- (変更なし)
439
  app = FastAPI()
440
 
 
441
  app.add_middleware(
442
  CORSMiddleware,
443
  allow_origins=["*"],
@@ -446,96 +562,89 @@ app.add_middleware(
446
  allow_headers=["*"],
447
  )
448
 
449
- # Gradio静的ファイルのマウントを改善
450
- try:
451
- gradio_dir = os.path.dirname(gr.__file__)
452
- logger.info(f"Gradio バージョン: {gr.__version__}")
453
- logger.info(f"Gradio ディレクトリ: {gradio_dir}")
454
-
455
- # SvelteKitベースの新しいフロントエンドパスを優先的にチェック
456
- frontend_dir = os.path.join(gradio_dir, "templates", "frontend")
457
- if os.path.exists(os.path.join(frontend_dir, "_app")):
458
- logger.info("新しいフロントエンド構造 (_app) を検出しました。")
459
- app_dir = os.path.join(frontend_dir, "_app")
460
- assets_dir = os.path.join(frontend_dir, "assets")
461
- static_dir = os.path.join(frontend_dir, "static") # staticも存在する可能性がある
462
-
463
- if os.path.exists(app_dir):
464
- app.mount("/_app", StaticFiles(directory=app_dir), name="_app")
465
- logger.info(f"マウント: /_app -> {app_dir}")
466
- if os.path.exists(assets_dir):
467
- app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
468
- logger.info(f"マウント: /assets -> {assets_dir}")
469
- if os.path.exists(static_dir):
470
- app.mount("/static", StaticFiles(directory=static_dir), name="static")
471
- logger.info(f"マウント: /static -> {static_dir}")
472
-
473
- else: # 古い構造の場合 (フォールバック)
474
- logger.warning("古いGradioフロントエンド構造の可能性があります。")
475
- static_dir_old = os.path.join(gradio_dir, "static")
476
- templates_dir_old = os.path.join(gradio_dir, "templates")
477
- if os.path.exists(static_dir_old):
478
- app.mount("/static", StaticFiles(directory=static_dir_old), name="static_old")
479
- logger.info(f"マウント (旧): /static -> {static_dir_old}")
480
- if os.path.exists(templates_dir_old):
481
- # templatesは通常、直接サーブしないが、念のためログに残す
482
- logger.info(f"テンプレートディレクトリ (旧): {templates_dir_old}")
483
-
484
- # 共通の可能性のあるCDNパス
485
- cdn_dir = os.path.join(gradio_dir, "templates", "cdn")
486
- if os.path.exists(cdn_dir):
487
- app.mount("/cdn", StaticFiles(directory=cdn_dir), name="cdn")
488
- logger.info(f"マウント: /cdn -> {cdn_dir}")
489
-
490
- except ImportError:
491
- logger.error("Gradioが見つかりません。UI機能は利用できません。")
492
- except Exception as e:
493
- logger.error(f"Gradio静的ファイルの検索中にエラー: {e}", exc_info=True)
494
-
495
-
496
- # API Endpoint for screenshot generation (変更なし)
497
  @app.post("/api/screenshot",
498
  response_class=StreamingResponse,
499
  tags=["Screenshot"],
500
- summary="HTMLからフルページスクリーンショットを生成",
501
- description="HTMLコードとオプションの縦方向拡張率を受け取り、ヘッドレスブラウザでレンダリングし、フルページスクリーンショットをPNG画像として返します。")
502
  async def api_render_screenshot(request: ScreenshotRequest):
 
 
 
503
  try:
504
- logger.info(f"HTML→スクリーンショットAPIリクエスト受信。拡張率: {request.extension_percentage}%")
 
505
  pil_image = render_fullpage_screenshot(
506
  request.html_code,
507
  request.extension_percentage,
508
  request.trim_whitespace
509
  )
510
 
511
- # エラー画像かどうかをサイズで判断(より確実に)
512
- if pil_image.size == (200, 50) and pil_image.getpixel((0,0)) == (255, 200, 200):
513
- logger.error("スクリーンショット生成に失敗。エラー画像が生成されました。")
514
- # 500エラーを返す方がAPIとしては適切かもしれない
515
- # raise HTTPException(status_code=500, detail="スクリーンショット生成に失敗しました")
516
 
 
517
  img_byte_arr = BytesIO()
518
  pil_image.save(img_byte_arr, format='PNG')
519
- img_byte_arr.seek(0)
520
 
521
- logger.info("スクリーンショットをPNGストリームとして返却します。")
522
  return StreamingResponse(img_byte_arr, media_type="image/png")
 
523
  except Exception as e:
524
- logger.error(f"APIエラー (/api/screenshot): {e}", exc_info=True)
525
- raise HTTPException(status_code=500, detail=f"内部サーバーエラー: {e}")
526
 
527
- # --- 新しいGemini API連携エンドポイント --- (変更なし)
528
  @app.post("/api/text-to-screenshot",
529
  response_class=StreamingResponse,
530
  tags=["Screenshot", "Gemini"],
531
  summary="テキストからインフォグラフィックを生成",
532
  description="テキストをGemini APIを使ってHTMLインフォグラフィックに変換し、スクリーンショットとして返します。")
533
  async def api_text_to_screenshot(request: GeminiRequest):
 
 
 
534
  try:
535
- logger.info(f"テキスト→スクリーンショットAPIリクエスト受信。テキスト長: {len(request.text)}, "
536
  f"拡張率: {request.extension_percentage}%, 温度: {request.temperature}, "
537
- f"スタイル: {request.style}, トリム: {request.trim_whitespace}")
538
 
 
539
  pil_image = text_to_screenshot(
540
  request.text,
541
  request.extension_percentage,
@@ -544,135 +653,665 @@ async def api_text_to_screenshot(request: GeminiRequest):
544
  request.style
545
  )
546
 
547
- # エラー画像チェック
548
- if pil_image.size == (200, 50) and pil_image.getpixel((0,0)) == (255, 200, 200):
549
- logger.error("テキストからのスクリーンショット生成に失敗。エラー画像が生成されました。")
550
- # raise HTTPException(status_code=500, detail="テキストからのスクリーンショット生成に失敗しました")
551
 
 
 
552
  img_byte_arr = BytesIO()
553
  pil_image.save(img_byte_arr, format='PNG')
554
- img_byte_arr.seek(0)
555
 
556
- logger.info("スクリーンショットをPNGストリームとして返却します。")
557
  return StreamingResponse(img_byte_arr, media_type="image/png")
558
- except Exception as e:
559
- logger.error(f"APIエラー (/api/text-to-screenshot): {e}", exc_info=True)
560
- # Gemini APIからのエラーもここでキャッチされる可能性あり
561
- raise HTTPException(status_code=500, detail=f"内部サーバーエラー: {e}")
562
 
 
 
 
563
 
564
- # --- Gradio Interface Definition --- (変更なし)
 
565
  def process_input(input_mode, input_text, extension_percentage, temperature, trim_whitespace, style):
566
  """入力モードに応じて適切な処理を行う"""
567
- logger.info(f"Gradio処理開始: モード={input_mode}, スタイル={style}, 温度={temperature}, 拡張={extension_percentage}%, トリム={trim_whitespace}")
568
- try:
569
- if input_mode == "HTML入力":
570
- result_image = render_fullpage_screenshot(input_text, extension_percentage, trim_whitespace)
571
- else: # テキスト入力
572
- result_image = text_to_screenshot(input_text, extension_percentage, temperature, trim_whitespace, style)
573
- logger.info("Gradio処理完了。")
574
- return result_image
575
- except Exception as e:
576
- logger.error(f"Gradio処理中にエラー: {e}", exc_info=True)
577
- # Gradioにエラーメッセージを表示させる(テキストや画像で)
578
- # ここではエラー画像表示のフォールバックに任せる
579
- error_img = Image.new('RGB', (300, 100), color = (255, 150, 150))
580
- # from PIL import ImageDraw
581
- # draw = ImageDraw.Draw(error_img)
582
- # draw.text((10, 10), f"エラーが発生しました:\n{type(e).__name__}\n詳細はログを確認してください。", fill=(0,0,0))
583
- return error_img # エラーを示す画像を返す
584
-
585
-
586
- # Gradio UIの定義 (gr.update の形式を修正)
587
- with gr.Blocks(title="Full Page Screenshot (テキスト変換対応)", theme=gr.themes.Default()) as iface: #テーマ変更
588
  gr.Markdown("# HTMLビューア & テキスト→インフォグラフィック変換")
589
  gr.Markdown("HTMLコードをレンダリングするか、テキストをGemini APIでインフォグラフィックに変換して画像として取得します。")
590
 
591
  with gr.Row():
592
  input_mode = gr.Radio(
593
- ["HTML入力", "テキスト入力"], label="入力モード", value="HTML入力"
 
 
594
  )
595
 
 
596
  input_text = gr.Textbox(
597
- lines=15, label="入力", placeholder="HTMLコードまたはテキストを入力してください...", show_copy_button=True
 
 
598
  )
599
 
600
  with gr.Row():
601
  with gr.Column(scale=1):
 
602
  style_dropdown = gr.Dropdown(
603
  choices=["standard", "cute", "resort", "cool", "dental"],
604
- value="standard", label="デザインスタイル",
605
- info="テキスト変換時のテーマ", visible=False
 
 
606
  )
 
 
 
 
 
 
 
 
 
 
 
607
  temperature = gr.Slider(
608
- minimum=0.0, maximum=1.0, step=0.1, value=0.5,
609
- label="温度 (低い=一貫性高, 高い=創造性高)", visible=False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
  )
 
611
  with gr.Column(scale=2):
612
  extension_percentage = gr.Slider(
613
- minimum=0, maximum=50, step=1.0, value=10, # 最大値を少し上げる
614
- label="上下高さ拡張率 (%)", info="レンダリング後の画像下部の余白調整"
 
 
 
615
  )
616
- trim_whitespace = gr.Checkbox(
617
- label="画像の余白を自動トリミング", value=True,
618
- info="生成画像の周囲の不要な空白を削除"
 
 
 
 
 
 
619
  )
620
 
621
- submit_btn = gr.Button("画像生成", variant="primary") # ボタンの見た目変更
622
- output_image = gr.Image(type="pil", label="生成された画像", show_download_button=True, interactive=False) # ダウンロードボタン表示、編集不可に
 
 
 
 
 
 
 
623
 
624
- # 入力モード変更時のイベント処理 (gr.updateを使用)
625
  def update_controls_visibility(mode):
626
- is_text_mode = (mode == "テキスト入力")
627
- # Gradio 4.x では辞書の代わりに gr.update() を返す
628
- return gr.update(visible=is_text_mode), gr.update(visible=is_text_mode)
 
 
 
629
 
630
  input_mode.change(
631
  fn=update_controls_visibility,
632
  inputs=input_mode,
633
- outputs=[style_dropdown, temperature] # 出力コンポーネントをリストで指定
634
  )
635
 
 
636
  submit_btn.click(
637
  fn=process_input,
638
  inputs=[input_mode, input_text, extension_percentage, temperature, trim_whitespace, style_dropdown],
639
  outputs=output_image
640
  )
641
 
642
- with gr.Accordion("詳細情報", open=False): # アコーディオンで非表示に
643
- gemini_model_display = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro (デフォルト)")
644
- gr.Markdown(f"""
645
- ### APIエンドポイント
646
- - **POST** `/api/screenshot`: HTMLコードからスクリーンショット生成
647
- - **POST** `/api/text-to-screenshot`: テキストからインフォグラフィック画像生成
648
-
649
- ### 設定情報
650
- - **使用Geminiモデル**: `{gemini_model_display}` (環境変数 `GEMINI_MODEL` で変更可能)
651
- - **対応スタイル**: `standard`, `cute`, `resort`, `cool`, `dental`
652
- - **Thinking Budget**: モデルが `gemini-2.5-flash-preview-04-17` の場合、自動的に `0` に設定されます。
653
- - **WebDriverパス**: 環境変数 `CHROMEDRIVER_PATH` で指定可能 (未指定時はPATHから検索)
654
- """)
655
 
656
  # --- Mount Gradio App onto FastAPI ---
657
- # Gradioアプリのマウント (エラーハンドリング追加)
658
- try:
659
- app = gr.mount_gradio_app(app, iface, path="/")
660
- logger.info("Gradioインターフェースを FastAPI にマウントしました。パス: /")
661
- except Exception as e:
662
- logger.error(f"Gradioアプリのマウントに失敗: {e}", exc_info=True)
663
- # GradioがマウントできなくてもFastAPI自体は起動できるようにする
664
- @app.get("/")
665
- async def root_fallback():
666
- return {"message": "FastAPI is running, but Gradio UI failed to mount. Check logs for details."}
667
 
668
  # --- Run with Uvicorn (for local testing) ---
669
  if __name__ == "__main__":
670
  import uvicorn
671
- # ポートを環境変数から取得、なければデフォルト7860
672
- port = int(os.environ.get("PORT", 7860))
673
- # ホストを環境変数から取得、なければデフォルト0.0.0.0
674
- host = os.environ.get("HOST", "0.0.0.0")
675
- logger.info(f"Uvicornサーバーを起動します: http://{host}:{port}")
676
- # リロード設定は開発時のみTrueにする (環境変数などで制御推奨)
677
- reload_flag = os.environ.get("UVICORN_RELOAD", "False").lower() == "true"
678
- uvicorn.run("__main__:app", host=host, port=port, reload=reload_flag)
 
 
1
  import gradio as gr
2
  from fastapi import FastAPI, HTTPException, Body
3
  from fastapi.responses import StreamingResponse
 
17
  import logging
18
  from huggingface_hub import hf_hub_download
19
 
20
+ # Correct Gemini imports, including types
21
  import google.generativeai as genai
22
+ from google.generativeai import types # Import types module needed for thinking_config
 
23
 
24
  # ロギング設定
25
  logging.basicConfig(level=logging.INFO)
 
29
  class GeminiRequest(BaseModel):
30
  """Geminiへのリクエストデータモデル"""
31
  text: str
32
+ extension_percentage: float = 10.0 # デフォルト値10%
33
+ temperature: float = 0.5 # デフォルト値を0.5に設定
34
+ trim_whitespace: bool = True # 余白トリミングオプション(デフォルト有効)
35
+ style: str = "standard" # デフォルトはstandard
36
 
37
  class ScreenshotRequest(BaseModel):
38
  """スクリーンショットリクエストモデル"""
39
  html_code: str
40
+ extension_percentage: float = 10.0 # デフォルト値10%
41
+ trim_whitespace: bool = True # 余白トリミングオプション(デフォルト有効)
42
+ style: str = "standard" # デフォルトはstandard # Note: Style is not used for direct HTML rendering
43
 
44
  # HTMLのFont Awesomeレイアウトを改善する関数
45
  def enhance_font_awesome_layout(html_code):
46
  """Font Awesomeレイアウトを改善するCSSを追加"""
47
+ # CSSを追加
48
  fa_fix_css = """
49
  <style>
50
  /* Font Awesomeアイコンのレイアウト修正 */
 
53
  margin-right: 8px !important;
54
  vertical-align: middle !important;
55
  }
56
+
57
+ /* テキスト内のアイコン位置調整 */
58
  h1 [class*="fa-"], h2 [class*="fa-"], h3 [class*="fa-"],
59
  h4 [class*="fa-"], h5 [class*="fa-"], h6 [class*="fa-"] {
60
  vertical-align: middle !important;
61
  margin-right: 10px !important;
62
  }
63
+
64
+ /* 特定パターンの修正 */
65
  .fa + span, .fas + span, .far + span, .fab + span,
66
+ span + .fa, span + .fas, span + .far, span + .fab {
67
  display: inline-block !important;
68
  margin-left: 5px !important;
69
  }
70
+
71
+ /* カード内アイコン修正 */
72
  .card [class*="fa-"], .card-body [class*="fa-"] {
73
  float: none !important;
74
  clear: none !important;
75
  position: relative !important;
76
  }
77
+
78
+ /* アイコンと文字が重なる場合の調整 */
79
  li [class*="fa-"], p [class*="fa-"] {
80
  margin-right: 10px !important;
81
  }
82
+
83
+ /* インラインアイコンのスペーシング */
84
  .inline-icon {
85
  display: inline-flex !important;
86
  align-items: center !important;
87
  justify-content: flex-start !important;
88
  }
89
+
90
+ /* アイコン後のテキスト */
91
  [class*="fa-"] + span {
92
  display: inline-block !important;
93
  vertical-align: middle !important;
94
  }
95
  </style>
96
  """
97
+
98
+ # headタグがある場合はその中に追加
99
  if '<head>' in html_code:
100
  return html_code.replace('</head>', f'{fa_fix_css}</head>')
101
+ # HTMLタグがある場合はその後に追加
102
  elif '<html' in html_code:
103
  head_end = html_code.find('</head>')
104
  if head_end > 0:
 
107
  body_start = html_code.find('<body')
108
  if body_start > 0:
109
  return html_code[:body_start] + f'<head>{fa_fix_css}</head>' + html_code[body_start:]
 
110
 
111
+ # どちらもない場合は先頭に追加
112
+ return f'<html><head>{fa_fix_css}</head>' + html_code + '</html>'
113
 
114
  def load_system_instruction(style="standard"):
115
+ """
116
+ 指定されたスタイルのシステムインストラクションを読み込む
117
+
118
+ Args:
119
+ style: 使用するスタイル名 (standard, cute, resort, cool, dental)
120
+
121
+ Returns:
122
+ 読み込まれたシステムインストラクション
123
+ """
124
  try:
125
+ # 有効なスタイル一覧
126
  valid_styles = ["standard", "cute", "resort", "cool", "dental"]
127
+
128
+ # スタイルの検証
129
  if style not in valid_styles:
130
+ logger.warning(f"無効なスタイル '{style}' が指定されました。デフォルトの 'standard' を使用します。")
131
  style = "standard"
132
+
133
  logger.info(f"スタイル '{style}' のシステムインストラクションを読み込みます")
134
 
135
+ # まず、ローカルのスタイルディレクトリ内のprompt.txtを確認
136
+ local_path = os.path.join(os.path.dirname(__file__), style, "prompt.txt")
137
+
138
+ # ローカルファイルが存在する場合はそれを使用
139
+ if os.path.exists(local_path):
140
+ logger.info(f"ローカルファイルを使用: {local_path}")
141
+ with open(local_path, 'r', encoding='utf-8') as file:
142
+ instruction = file.read()
143
+ return instruction
144
+
145
+ # HuggingFaceリポジトリからのファイル読み込みを試行
 
 
 
146
  try:
147
+ # スタイル固有のファイルパスを指定
148
  file_path = hf_hub_download(
149
  repo_id="tomo2chin2/GURAREKOstlyle",
150
  filename=f"{style}/prompt.txt",
151
  repo_type="dataset"
152
  )
153
+
154
+ logger.info(f"スタイル '{style}' のプロンプトをHuggingFaceから読み込みました: {file_path}")
155
  with open(file_path, 'r', encoding='utf-8') as file:
156
+ instruction = file.read()
157
+ return instruction
158
+
159
  except Exception as style_error:
160
+ # スタイル固有ファイルの読み込みに失敗した場合、デフォルトのprompt.txtを使用
161
+ logger.warning(f"スタイル '{style}' のプロンプト読み込みに失敗: {str(style_error)}")
162
+ logger.info("デフォルトのprompt.txtを読み込みます")
163
+
164
  file_path = hf_hub_download(
165
  repo_id="tomo2chin2/GURAREKOstlyle",
166
  filename="prompt.txt",
167
  repo_type="dataset"
168
  )
169
+
170
  with open(file_path, 'r', encoding='utf-8') as file:
171
  instruction = file.read()
172
+
173
  logger.info("デフォルトのシステムインストラクションを読み込みました")
174
  return instruction
175
+
176
  except Exception as e:
177
+ error_msg = f"システムインストラクションの読み込みに失敗: {str(e)}"
178
  logger.error(error_msg)
179
  raise ValueError(error_msg)
180
 
181
+
182
  def generate_html_from_text(text, temperature=0.3, style="standard"):
183
+ """テキストからHTMLを生成する"""
184
  try:
185
+ # APIキーの取得と設定
186
  api_key = os.environ.get("GEMINI_API_KEY")
187
  if not api_key:
188
  logger.error("GEMINI_API_KEY 環境変数が設定されていません")
189
  raise ValueError("GEMINI_API_KEY 環境変数が設定されていません")
190
 
191
+ # モデル名の取得(環境変数から、なければデフォルト値)
192
+ # ユーザーの要望に従い、環境変数が設定されていない場合のデフォルト値を gemini-1.5-pro のままにする
193
  model_name = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
194
  logger.info(f"使用するGeminiモデル: {model_name}")
195
 
196
+ # Gemini APIの設定
197
  genai.configure(api_key=api_key)
198
+
199
+ # 指定されたスタイルのシステムインストラクションを読み込む
200
  system_instruction = load_system_instruction(style)
 
201
 
202
+ # モデル初期化
203
+ model = genai.GenerativeModel(model_name)
204
 
205
+ # 生成設定 - ばらつきを減らすために設定を調整
206
+ # generation_config は辞書として定義し、thinking_config を後で追加する可能性がある
207
+ generation_config_dict = {
208
+ "temperature": temperature, # APIリクエストで指定された温度を使用
209
  "top_p": 0.7,
210
  "top_k": 20,
211
  "max_output_tokens": 8192,
212
+ "candidate_count": 1,
213
  }
214
 
215
+ # モデルが gemini-2.5-flash-preview-04-17 の場合のみ thinking_budget=0 を設定
216
  if model_name == "gemini-2.5-flash-preview-04-17":
217
+ logger.info(f"Model {model_name} detected, setting thinking_budget=0")
218
+ # thinking_config generation_config_dict に追加
219
+ generation_config_dict["thinking_config"] = types.ThinkingConfig(thinking_budget=0)
220
+ # 他のモデルの場合は thinking_config を追加しない(デフォルト動作)
 
221
 
 
 
222
 
223
+ logger.info(f"Gemini APIにリクエストを送信: テキスト長さ = {len(text)}, 温度 = {temperature}, スタイル = {style}, モデル = {model_name}, Generation Config = {generation_config_dict}")
 
 
 
 
 
 
 
 
224
 
225
+ # コンテンツ生成
226
+ # generation_config パラメータに設定済みの辞書を渡す
227
  response = model.generate_content(
228
+ prompt=f"{system_instruction}\n\n{text}",
229
+ generation_config=generation_config_dict,
230
  safety_settings=safety_settings
231
  )
232
 
233
+ # レスポンスからHTMLを抽出
234
  raw_response = response.text
235
+
236
+ # HTMLタグ部分だけを抽出(```html と ``` の間)
237
  html_start = raw_response.find("```html")
238
  html_end = raw_response.rfind("```")
239
 
240
  if html_start != -1 and html_end != -1 and html_start < html_end:
241
+ html_start += 7 # "```html" の長さ分進める
242
  html_code = raw_response[html_start:html_end].strip()
243
+ logger.info(f"HTMLの生成に成功: 長さ = {len(html_code)}")
244
+
245
+ # Font Awesomeのレイアウト改善
246
  html_code = enhance_font_awesome_layout(html_code)
247
+ logger.info("Font Awesomeレイアウトの最適化を適用しました")
248
+
249
  return html_code
250
  else:
251
+ # HTMLタグが見つからない場合、レスポンス全体を返す
252
+ logger.warning("レスポンスから ```html ``` タグが見つかりませんでした。全テキストを返します。")
253
+ return raw_response
 
 
 
 
 
 
 
254
 
255
  except Exception as e:
256
  logger.error(f"HTML生成中にエラーが発生: {e}", exc_info=True)
257
+ raise Exception(f"Gemini APIでのHTML生成に失敗しました: {e}")
 
 
258
 
259
 
260
+ # 画像から余分な空白領域をトリミングする関数
261
  def trim_image_whitespace(image, threshold=250, padding=10):
262
+ """
263
+ 画像から余分な白い空白をトリミングする
264
+
265
+ Args:
266
+ image: PIL.Image - 入力画像
267
+ threshold: int - どの明るさ以上を空白と判断するか (0-255)
268
+ padding: int - トリミング後に残す余白のピクセル数
269
+
270
+ Returns:
271
+ トリミングされたPIL.Image
272
+ """
273
+ # グレースケールに変換
274
  gray = image.convert('L')
275
+
276
+ # ピクセルデータを配列として取得
277
  data = gray.getdata()
278
  width, height = gray.size
279
+
280
+ # 有効範囲を見つける
281
  min_x, min_y = width, height
282
  max_x = max_y = 0
283
+
284
+ # ピクセルデータを2次元配列に変換して処理
285
  pixels = list(data)
286
  pixels = [pixels[i * width:(i + 1) * width] for i in range(height)]
287
+
288
+ # 各行をスキャンして非空白ピクセルを見つける
289
  for y in range(height):
290
  for x in range(width):
291
+ if pixels[y][x] < threshold: # 非空白ピクセル
292
  min_x = min(min_x, x)
293
  min_y = min(min_y, y)
294
  max_x = max(max_x, x)
295
  max_y = max(max_y, y)
296
+ # 境界外のトリミングの場合はエラー
297
  if min_x > max_x or min_y > max_y:
298
  logger.warning("トリミング領域が見つかりません。元の画像を返します。")
299
  return image
300
+
301
+ # パディングを追加
302
  min_x = max(0, min_x - padding)
303
  min_y = max(0, min_y - padding)
304
  max_x = min(width - 1, max_x + padding)
305
  max_y = min(height - 1, max_y + padding)
306
+
307
+ # 画像をトリミング
308
  trimmed = image.crop((min_x, min_y, max_x + 1, max_y + 1))
309
+
310
+ logger.info(f"画像をトリミングしました: 元サイズ {width}x{height} → トリミング後 {trimmed.width}x{trimmed.height}")
311
  return trimmed
312
 
313
+ # 非同期スクリプトを使わず、同期的なスクリプトのみ使用する改善版
314
+
315
  def render_fullpage_screenshot(html_code: str, extension_percentage: float = 6.0,
316
  trim_whitespace: bool = True) -> Image.Image:
317
+ """
318
+ Renders HTML code to a full-page screenshot using Selenium.
319
+
320
+ Args:
321
+ html_code: The HTML source code string.
322
+ extension_percentage: Percentage of extra space to add vertically.
323
+ trim_whitespace: Whether to trim excess whitespace from the image.
324
+
325
+ Returns:
326
+ A PIL Image object of the screenshot.
327
+ """
328
  tmp_path = None
329
  driver = None
330
+
331
+ # 1) Save HTML code to a temporary file
332
  try:
333
  with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode='w', encoding='utf-8') as tmp_file:
334
  tmp_path = tmp_file.name
335
  tmp_file.write(html_code)
336
+ logger.info(f"HTML saved to temporary file: {tmp_path}")
337
+ except Exception as e:
338
+ logger.error(f"Error writing temporary HTML file: {e}", exc_info=True)
339
+ return Image.new('RGB', (1, 1), color=(0, 0, 0))
340
+
341
+ # 2) Headless Chrome(Chromium) options
342
+ options = Options()
343
+ options.add_argument("--headless")
344
+ options.add_argument("--no-sandbox")
345
+ options.add_argument("--disable-dev-shm-usage")
346
+ options.add_argument("--force-device-scale-factor=1")
347
+ options.add_argument("--disable-features=NetworkService")
348
+ options.add_argument("--dns-prefetch-disable")
349
+ # 環境変数からWebDriverパスを取得(任意)
350
+ webdriver_path = os.environ.get("CHROMEDRIVER_PATH")
351
+ service = None
352
+ if webdriver_path and os.path.exists(webdriver_path):
353
+ logger.info(f"Using CHROMEDRIVER_PATH: {webdriver_path}")
354
+ try:
355
+ service = webdriver.ChromeService(executable_path=webdriver_path)
356
+ except Exception as e:
357
+ logger.error(f"Error creating ChromeService with {webdriver_path}: {e}", exc_info=True)
358
+ service = None # Fallback to default
359
+ if service is None:
360
+ logger.info("CHROMEDRIVER_PATH not set or invalid, using default PATH lookup for WebDriver.")
361
 
362
+ try:
363
+ logger.info("Initializing WebDriver...")
364
  if service:
365
+ driver = webdriver.Chrome(service=service, options=options)
366
  else:
367
+ driver = webdriver.Chrome(options=options)
368
+ logger.info("WebDriver initialized.")
369
 
370
+ # 3) 初期ウィンドウサイズを設定
371
+ initial_width = 1200
372
+ initial_height = 1000
373
+ driver.set_window_size(initial_width, initial_height)
374
  file_url = "file://" + tmp_path
375
+ logger.info(f"Navigating to {file_url}")
376
  driver.get(file_url)
377
 
378
+ # 4) ページ読み込み待機
379
+ logger.info("Waiting for body element...")
380
+ WebDriverWait(driver, 15).until(
381
  EC.presence_of_element_located((By.TAG_NAME, "body"))
382
  )
383
+ logger.info("Body element found. Waiting for resource loading...")
384
+
385
+ # 5) 基本的なリソース読み込み待機 - タイムアウト回避
386
+ time.sleep(3)
387
+
388
+ # Font Awesome読み込み確認 - 非同期を使わない
389
+ logger.info("Checking for Font Awesome resources...")
390
+ fa_count = driver.execute_script("""
391
+ var icons = document.querySelectorAll('.fa, .fas, .far, .fab, [class*="fa-"]');
392
+ return icons.length;
393
+ """)
394
+ logger.info(f"Found {fa_count} Font Awesome elements")
395
+
396
+ # リソース読み込み状態を確認
397
+ doc_ready = driver.execute_script("return document.readyState;")
398
+ logger.info(f"Document ready state: {doc_ready}")
399
+
400
+ # Font Awesomeが多い場合は追加待機
401
+ if fa_count > 50:
402
+ logger.info("Many Font Awesome icons detected, waiting additional time")
403
+ time.sleep(2)
404
+
405
+ # 6) コンテンツレンダリングのためのスクロール処理 - 同期的に実行
406
+ logger.info("Performing content rendering scroll...")
407
+ total_height = driver.execute_script("return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);")
408
+ viewport_height = driver.execute_script("return window.innerHeight;")
409
+ scrolls_needed = max(1, total_height // viewport_height)
410
+
411
+ for i in range(scrolls_needed + 1):
412
+ scroll_pos = i * (viewport_height - 200) # オーバーラ��プさせる
413
+ driver.execute_script(f"window.scrollTo(0, {scroll_pos});")
414
+ time.sleep(0.2) # 短い待機
415
+
416
+ # トップに戻る
417
+ driver.execute_script("window.scrollTo(0, 0);")
418
  time.sleep(0.5)
419
+ logger.info("Scroll rendering completed")
420
 
421
+ # 7) スクロールバーを非表示に
422
+ driver.execute_script("""
423
+ document.documentElement.style.overflow = 'hidden';
424
+ document.body.style.overflow = 'hidden';
425
+ """)
426
+ logger.info("Scrollbars hidden")
427
 
428
+ # 8) ページの寸法を取得
429
  dimensions = driver.execute_script("""
430
  return {
431
+ width: Math.max(
432
+ document.documentElement.scrollWidth,
433
+ document.documentElement.offsetWidth,
434
+ document.documentElement.clientWidth,
435
+ document.body ? document.body.scrollWidth : 0,
436
+ document.body ? document.body.offsetWidth : 0,
437
+ document.body ? document.body.clientWidth : 0
438
+ ),
439
+ height: Math.max(
440
+ document.documentElement.scrollHeight,
441
+ document.documentElement.offsetHeight,
442
+ document.documentElement.clientHeight,
443
+ document.body ? document.body.scrollHeight : 0,
444
+ document.body ? document.body.offsetHeight : 0,
445
+ document.body ? document.body.clientHeight : 0
446
+ )
447
  };
448
  """)
449
  scroll_width = dimensions['width']
450
  scroll_height = dimensions['height']
451
+ logger.info(f"Detected dimensions: width={scroll_width}, height={scroll_height}")
452
 
453
+ # 再検証 - 短いスクロールで再確認
454
+ driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
455
+ time.sleep(0.5)
456
+ driver.execute_script("window.scrollTo(0, 0);")
457
+ time.sleep(0.5)
458
+
459
+ dimensions_after = driver.execute_script("return {height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)};")
460
+ scroll_height = max(scroll_height, dimensions_after['height'])
461
+ logger.info(f"After scroll check, height={scroll_height}")
462
+
463
+ # 最小/最大値の設定
464
+ scroll_width = max(scroll_width, 100)
465
+ scroll_height = max(scroll_height, 100)
466
+ scroll_width = min(scroll_width, 2000)
467
+ scroll_height = min(scroll_height, 4000)
468
 
469
+ # 9) レイアウト安定化のための単純な待機 - タイムアウト回避
470
+ logger.info("Waiting for layout stabilization...")
471
+ time.sleep(2)
472
 
473
+ # 10) 高さに余白を追加
474
  adjusted_height = int(scroll_height * (1 + extension_percentage / 100.0))
475
+ adjusted_height = max(adjusted_height, scroll_height, 100)
476
+ logger.info(f"Adjusted height calculated: {adjusted_height} (extension: {extension_percentage}%)")
477
 
478
+ # 11) ウィンドウサイズを調整
479
  adjusted_width = scroll_width
480
+ logger.info(f"Resizing window to: width={adjusted_width}, height={adjusted_height}")
481
+ driver.set_window_size(adjusted_width, adjusted_height)
482
+ time.sleep(1)
 
 
 
 
 
 
483
 
484
+ # リソース状態を確認 - 同期的スクリプト
485
+ resource_state = driver.execute_script("""
486
+ return {
487
+ readyState: document.readyState,
488
+ resourcesComplete: !document.querySelector('img:not([complete])') &&
489
+ !document.querySelector('link[rel="stylesheet"]:not([loaded])')
490
+ };
491
+ """)
492
+ logger.info(f"Resource state: {resource_state}")
493
 
494
+ if resource_state['readyState'] != 'complete':
495
+ logger.info("Document still loading, waiting additional time...")
496
+ time.sleep(1)
497
 
498
+ # トップにスクロール
499
+ driver.execute_script("window.scrollTo(0, 0);")
500
+ time.sleep(0.5)
501
+ logger.info("Scrolled to top.")
 
 
 
 
 
 
502
 
503
+ # 12) スクリーンショット取得
504
+ logger.info("Taking screenshot...")
505
+ png = driver.get_screenshot_as_png()
506
+ logger.info("Screenshot taken successfully.")
507
 
508
+ # PIL画像に変換
509
  img = Image.open(BytesIO(png))
510
+ logger.info(f"Screenshot dimensions: {img.width}x{img.height}")
511
 
512
+ # 余白トリミング
513
  if trim_whitespace:
514
  img = trim_image_whitespace(img, threshold=248, padding=20)
515
+ logger.info(f"Trimmed dimensions: {img.width}x{img.height}")
516
 
517
  return img
518
 
519
  except Exception as e:
520
+ logger.error(f"Error during screenshot generation: {e}", exc_info=True)
521
+ # Return a small black image on error
522
+ return Image.new('RGB', (1, 1), color=(0, 0, 0))
 
 
 
 
 
523
  finally:
524
+ logger.info("Cleaning up...")
525
  if driver:
526
  try:
527
  driver.quit()
528
+ logger.info("WebDriver quit successfully.")
529
  except Exception as e:
530
+ logger.error(f"Error quitting WebDriver: {e}", exc_info=True)
531
  if tmp_path and os.path.exists(tmp_path):
532
  try:
533
  os.remove(tmp_path)
534
+ logger.info(f"Temporary file {tmp_path} removed.")
535
  except Exception as e:
536
+ logger.error(f"Error removing temporary file {tmp_path}: {e}", exc_info=True)
537
 
538
 
539
+ # --- Geminiを使った新しい関数 ---
540
  def text_to_screenshot(text: str, extension_percentage: float, temperature: float = 0.3,
541
  trim_whitespace: bool = True, style: str = "standard") -> Image.Image:
542
  """テキストをGemini APIでHTMLに変換し、スクリーンショットを生成する統合関数"""
543
  try:
544
+ # 1. テキストからHTMLを生成(温度パラメータとスタイルも渡す)
545
  html_code = generate_html_from_text(text, temperature, style)
546
+
547
+ # 2. HTMLからスクリーンショットを生成
548
  return render_fullpage_screenshot(html_code, extension_percentage, trim_whitespace)
549
  except Exception as e:
550
+ logger.error(f"テキストからスクリーンショット生成中にエラーが発生: {e}", exc_info=True)
551
+ return Image.new('RGB', (1, 1), color=(0, 0, 0)) # エラー時は黒画像
 
 
552
 
553
+ # --- FastAPI Setup ---
554
  app = FastAPI()
555
 
556
+ # CORS設定を追加
557
  app.add_middleware(
558
  CORSMiddleware,
559
  allow_origins=["*"],
 
562
  allow_headers=["*"],
563
  )
564
 
565
+ # 静的ファイルのサービング設定
566
+ # Gradioのディレクトリを探索してアセットを見つける
567
+ gradio_dir = os.path.dirname(gr.__file__)
568
+ logger.info(f"Gradio version: {gr.__version__}")
569
+ logger.info(f"Gradio directory: {gradio_dir}")
570
+
571
+ # 基本的な静的ファイルディレクトリをマウント
572
+ static_dir = os.path.join(gradio_dir, "templates", "frontend", "static")
573
+ if os.path.exists(static_dir):
574
+ logger.info(f"Mounting static directory: {static_dir}")
575
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
576
+
577
+ # _appディレクトリを探す(新しいSvelteKitベースのフロントエンド用)
578
+ app_dir = os.path.join(gradio_dir, "templates", "frontend", "_app")
579
+ if os.path.exists(app_dir):
580
+ logger.info(f"Mounting _app directory: {app_dir}")
581
+ app.mount("/_app", StaticFiles(directory=app_dir), name="_app")
582
+
583
+ # assetsディレクトリを探す
584
+ assets_dir = os.path.join(gradio_dir, "templates", "frontend", "assets")
585
+ if os.path.exists(assets_dir):
586
+ logger.info(f"Mounting assets directory: {assets_dir}")
587
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
588
+
589
+ # cdnディレクトリがあれば追加
590
+ cdn_dir = os.path.join(gradio_dir, "templates", "cdn")
591
+ if os.path.exists(cdn_dir):
592
+ logger.info(f"Mounting cdn directory: {cdn_dir}")
593
+ app.mount("/cdn", StaticFiles(directory=cdn_dir), name="cdn")
594
+
595
+
596
+ # API Endpoint for screenshot generation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
597
  @app.post("/api/screenshot",
598
  response_class=StreamingResponse,
599
  tags=["Screenshot"],
600
+ summary="Render HTML to Full Page Screenshot",
601
+ 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.")
602
  async def api_render_screenshot(request: ScreenshotRequest):
603
+ """
604
+ API endpoint to render HTML and return a screenshot.
605
+ """
606
  try:
607
+ logger.info(f"API request received. Extension: {request.extension_percentage}%")
608
+ # Run the blocking Selenium code in a separate thread (FastAPI handles this)
609
  pil_image = render_fullpage_screenshot(
610
  request.html_code,
611
  request.extension_percentage,
612
  request.trim_whitespace
613
  )
614
 
615
+ if pil_image.size == (1, 1):
616
+ logger.error("Screenshot generation failed, returning 1x1 error image.")
617
+ # Optionally return a proper error response instead of 1x1 image
618
+ # raise HTTPException(status_code=500, detail="Failed to generate screenshot")
 
619
 
620
+ # Convert PIL Image to PNG bytes
621
  img_byte_arr = BytesIO()
622
  pil_image.save(img_byte_arr, format='PNG')
623
+ img_byte_arr.seek(0) # Go to the start of the BytesIO buffer
624
 
625
+ logger.info("Returning screenshot as PNG stream.")
626
  return StreamingResponse(img_byte_arr, media_type="image/png")
627
+
628
  except Exception as e:
629
+ logger.error(f"API Error: {e}", exc_info=True)
630
+ raise HTTPException(status_code=500, detail=f"Internal Server Error: {e}")
631
 
632
+ # --- 新しいGemini API連携エンドポイント ---
633
  @app.post("/api/text-to-screenshot",
634
  response_class=StreamingResponse,
635
  tags=["Screenshot", "Gemini"],
636
  summary="テキストからインフォグラフィックを生成",
637
  description="テキストをGemini APIを使ってHTMLインフォグラフィックに変換し、スクリーンショットとして返します。")
638
  async def api_text_to_screenshot(request: GeminiRequest):
639
+ """
640
+ テキストからHTMLインフォグラフィックを生成してスクリーンショットを返すAPIエンドポイント
641
+ """
642
  try:
643
+ logger.info(f"テキスト→スクリーンショットAPIリクエスト受信。テキスト長さ: {len(request.text)}, "
644
  f"拡張率: {request.extension_percentage}%, 温度: {request.temperature}, "
645
+ f"スタイル: {request.style}")
646
 
647
+ # テキストからHTMLを生成してスクリーンショットを作成(温度パラメータとスタイルも渡す)
648
  pil_image = text_to_screenshot(
649
  request.text,
650
  request.extension_percentage,
 
653
  request.style
654
  )
655
 
656
+ if pil_image.size == (1, 1):
657
+ logger.error("スクリーンショット生成に失敗しました。1x1エラー画像を返します。")
658
+ # raise HTTPException(status_code=500, detail="スクリーンショット生成に失敗しました")
 
659
 
660
+
661
+ # PIL画像をPNGバイトに変換
662
  img_byte_arr = BytesIO()
663
  pil_image.save(img_byte_arr, format='PNG')
664
+ img_byte_arr.seek(0) # BytesIOバッファの先頭に戻る
665
 
666
+ logger.info("スクリーンショットをPNGストリームとして返します。")
667
  return StreamingResponse(img_byte_arr, media_type="image/png")
 
 
 
 
668
 
669
+ except Exception as e:
670
+ logger.error(f"API Error: {e}", exc_info=True)
671
+ raise HTTPException(status_code=500, detail=f"Internal Server Error: {e}")
672
 
673
+ # --- Gradio Interface Definition ---
674
+ # 入力モードの選択用Radioコンポーネント
675
  def process_input(input_mode, input_text, extension_percentage, temperature, trim_whitespace, style):
676
  """入力モードに応じて適切な処理を行う"""
677
+ if input_mode == "HTML入力":
678
+ # HTMLモードの場合は既存の処理(スタイルは使わない)
679
+ return render_fullpage_screenshot(input_text, extension_percentage, trim_whitespace)
680
+ else:
681
+ # テキスト入力モードの場合はGemini APIを使用
682
+ return text_to_screenshot(input_text, extension_percentage, temperature, trim_whitespace, style)
683
+
684
+ # Gradio UIの定義
685
+ with gr.Blocks(title="Full Page Screenshot (テキスト変換対応)", theme=gr.themes.Base()) as iface:
 
 
 
 
 
 
 
 
 
 
 
 
686
  gr.Markdown("# HTMLビューア & テキスト→インフォグラフィック変換")
687
  gr.Markdown("HTMLコードをレンダリングするか、テキストをGemini APIでインフォグラフィックに変換して画像として取得します。")
688
 
689
  with gr.Row():
690
  input_mode = gr.Radio(
691
+ ["HTML入力", "テキスト入力"],
692
+ label="入力モード",
693
+ value="HTML入力"
694
  )
695
 
696
+ # 共用のテキストボックス
697
  input_text = gr.Textbox(
698
+ lines=15,
699
+ label="入力",
700
+ placeholder="HTMLコードまたはテキストを入力してください。入力モードに応じて処理されます。"
701
  )
702
 
703
  with gr.Row():
704
  with gr.Column(scale=1):
705
+ # スタイル選択ドロップダウン
706
  style_dropdown = gr.Dropdown(
707
  choices=["standard", "cute", "resort", "cool", "dental"],
708
+ value="standard",
709
+ label="デザインスタイル",
710
+ info="テキスト→HTML変換時のデザインテーマを選択します",
711
+ visible=False # テキスト入力モードの時だけ表示
712
  )
713
+
714
+ with gr.Column(scale=2):
715
+ extension_percentage = gr.Slider(
716
+ minimum=0,
717
+ maximum=30,
718
+ step=1.0,
719
+ value=10, # デフォルト値10%
720
+ label="上下高さ拡張率(%)"
721
+ )
722
+
723
+ # 温度調整スライダー(テキストモード時のみ表示)
724
  temperature = gr.Slider(
725
+ minimum=0.0,
726
+ maximum=1.0,
727
+ step=0.1,
728
+ value=0.5, # デフォルト値を0.5に設定
729
+ label="生成時の温度(低い=一貫性高、高い=創造性高)",
730
+ visible=False # 最初は非表示
731
+ )
732
+
733
+ # 余白トリミングオプション
734
+ trim_whitespace = gr.Checkbox(
735
+ label="余白を自動トリミング",
736
+ value=True,
737
+ info="生成される画像から余分な空白領域を自動的に削除します"
738
+ )
739
+
740
+ submit_btn = gr.Button("生成")
741
+ output_image = gr.Image(type="pil", label="ページ全体のスクリーンショット")
742
+
743
+ # 入力モード変更時のイベント処理(テキストモード時のみ温度スライダーとスタイルドロップダウンを表示)
744
+ def update_controls_visibility(mode):
745
+ # Gradio 4.x用のアップデート方法
746
+ is_text_mode = mode == "テキスト入力"
747
+ return [
748
+ gr.update(visible=is_text_mode), # temperature
749
+ gr.update(visible=is_text_mode), # style_dropdown
750
+ ]
751
+
752
+ input_mode.change(
753
+ fn=update_controls_visibility,
754
+ inputs=input_mode,
755
+ outputs=[temperature, style_dropdown]
756
+ )
757
+
758
+ # 生成ボタンクリック時のイベント処理
759
+ submit_btn.click(
760
+ fn=process_input,
761
+ inputs=[input_mode, input_text, extension_percentage, temperature, trim_whitespace, style_dropdown],
762
+ outputs=output_image
763
+ )
764
+
765
+ # 環境変数情報を表示
766
+ gemini_model = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
767
+ gr.Markdown(f"""
768
+ ## APIエンドポイント
769
+ - `/api/screenshot` - HTMLコードからスクリーンショットを生成
770
+ - `/api/text-to-screenshot` - テキストからインフォグラフィックスクリーンショットを生成
771
+
772
+ ## 設定情報
773
+ - 使用モデル: {gemini_model} (環境変数 GEMINI_MODEL で変更可能)
774
+ - 対応スタイル: standard, cute, resort, cool, dental
775
+ """)
776
+
777
+ # --- Mount Gradio App onto FastAPI ---
778
+ app = gr.mount_gradio_app(app, iface, path="/")
779
+
780
+ # --- Run with Uvicorn (for local testing) ---
781
+ if __name__ == "__main__":
782
+ import uvicorn
783
+ logger.info("Starting Uvicorn server for local development...")
784
+ uvicorn.run(app, host="0.0.0.0", port=7860)
785
+ `````` タグが見つかりませんでした。全テキストを返します。")
786
+ return raw_response
787
+
788
+ except Exception as e:
789
+ logger.error(f"HTML生成中にエラーが発生: {e}", exc_info=True)
790
+ raise Exception(f"Gemini APIでのHTML生成に失敗しました: {e}")
791
+
792
+
793
+ # 画像から余分な空白領域をトリミングする関数
794
+ def trim_image_whitespace(image, threshold=250, padding=10):
795
+ """
796
+ 画像から余分な白い空白をトリミングする
797
+
798
+ Args:
799
+ image: PIL.Image - 入力画像
800
+ threshold: int - どの明るさ以上を空白と判断するか (0-255)
801
+ padding: int - トリミング後に残す余白のピクセル数
802
+
803
+ Returns:
804
+ トリミングされたPIL.Image
805
+ """
806
+ # グレースケールに変換
807
+ gray = image.convert('L')
808
+
809
+ # ピクセルデータを配列として取得
810
+ data = gray.getdata()
811
+ width, height = gray.size
812
+
813
+ # 有効範囲を見つける
814
+ min_x, min_y = width, height
815
+ max_x = max_y = 0
816
+
817
+ # ピクセルデータを2次元配列に変換して処理
818
+ pixels = list(data)
819
+ pixels = [pixels[i * width:(i + 1) * width] for i in range(height)]
820
+
821
+ # 各行をスキャンして非空白ピクセルを見つける
822
+ for y in range(height):
823
+ for x in range(width):
824
+ if pixels[y][x] < threshold: # 非空白ピクセル
825
+ min_x = min(min_x, x)
826
+ min_y = min(min_y, y)
827
+ max_x = max(max_x, x)
828
+ max_y = max(max_y, y)
829
+ # 境界外のトリミングの場合はエラー
830
+ if min_x > max_x or min_y > max_y:
831
+ logger.warning("トリミング領域が見つかりません。元の画像を返します。")
832
+ return image
833
+
834
+ # パディングを追加
835
+ min_x = max(0, min_x - padding)
836
+ min_y = max(0, min_y - padding)
837
+ max_x = min(width - 1, max_x + padding)
838
+ max_y = min(height - 1, max_y + padding)
839
+
840
+ # 画像をトリミング
841
+ trimmed = image.crop((min_x, min_y, max_x + 1, max_y + 1))
842
+
843
+ logger.info(f"画像をトリミングしました: 元サイズ {width}x{height} → トリミング後 {trimmed.width}x{trimmed.height}")
844
+ return trimmed
845
+
846
+ # 非同期スクリプトを使わず、同期的なスクリプトのみ使用する改善���
847
+
848
+ def render_fullpage_screenshot(html_code: str, extension_percentage: float = 6.0,
849
+ trim_whitespace: bool = True) -> Image.Image:
850
+ """
851
+ Renders HTML code to a full-page screenshot using Selenium.
852
+
853
+ Args:
854
+ html_code: The HTML source code string.
855
+ extension_percentage: Percentage of extra space to add vertically.
856
+ trim_whitespace: Whether to trim excess whitespace from the image.
857
+
858
+ Returns:
859
+ A PIL Image object of the screenshot.
860
+ """
861
+ tmp_path = None
862
+ driver = None
863
+
864
+ # 1) Save HTML code to a temporary file
865
+ try:
866
+ with tempfile.NamedTemporaryFile(suffix=".html", delete=False, mode='w', encoding='utf-8') as tmp_file:
867
+ tmp_path = tmp_file.name
868
+ tmp_file.write(html_code)
869
+ logger.info(f"HTML saved to temporary file: {tmp_path}")
870
+ except Exception as e:
871
+ logger.error(f"Error writing temporary HTML file: {e}", exc_info=True)
872
+ return Image.new('RGB', (1, 1), color=(0, 0, 0))
873
+
874
+ # 2) Headless Chrome(Chromium) options
875
+ options = Options()
876
+ options.add_argument("--headless")
877
+ options.add_argument("--no-sandbox")
878
+ options.add_argument("--disable-dev-shm-usage")
879
+ options.add_argument("--force-device-scale-factor=1")
880
+ options.add_argument("--disable-features=NetworkService")
881
+ options.add_argument("--dns-prefetch-disable")
882
+ # 環境変数からWebDriverパスを取得(任意)
883
+ webdriver_path = os.environ.get("CHROMEDRIVER_PATH")
884
+ service = None
885
+ if webdriver_path and os.path.exists(webdriver_path):
886
+ logger.info(f"Using CHROMEDRIVER_PATH: {webdriver_path}")
887
+ try:
888
+ service = webdriver.ChromeService(executable_path=webdriver_path)
889
+ except Exception as e:
890
+ logger.error(f"Error creating ChromeService with {webdriver_path}: {e}", exc_info=True)
891
+ service = None # Fallback to default
892
+ if service is None:
893
+ logger.info("CHROMEDRIVER_PATH not set or invalid, using default PATH lookup for WebDriver.")
894
+
895
+ try:
896
+ logger.info("Initializing WebDriver...")
897
+ if service:
898
+ driver = webdriver.Chrome(service=service, options=options)
899
+ else:
900
+ driver = webdriver.Chrome(options=options)
901
+ logger.info("WebDriver initialized.")
902
+
903
+ # 3) 初期ウィンドウサイズを設定
904
+ initial_width = 1200
905
+ initial_height = 1000
906
+ driver.set_window_size(initial_width, initial_height)
907
+ file_url = "file://" + tmp_path
908
+ logger.info(f"Navigating to {file_url}")
909
+ driver.get(file_url)
910
+
911
+ # 4) ページ読み込み待機
912
+ logger.info("Waiting for body element...")
913
+ WebDriverWait(driver, 15).until(
914
+ EC.presence_of_element_located((By.TAG_NAME, "body"))
915
+ )
916
+ logger.info("Body element found. Waiting for resource loading...")
917
+
918
+ # 5) 基本的なリソース読み込み待機 - タイムアウト回避
919
+ time.sleep(3)
920
+
921
+ # Font Awesome読み込み確認 - 非同期を使わない
922
+ logger.info("Checking for Font Awesome resources...")
923
+ fa_count = driver.execute_script("""
924
+ var icons = document.querySelectorAll('.fa, .fas, .far, .fab, [class*="fa-"]');
925
+ return icons.length;
926
+ """)
927
+ logger.info(f"Found {fa_count} Font Awesome elements")
928
+
929
+ # リソース読み込み状態を確認
930
+ doc_ready = driver.execute_script("return document.readyState;")
931
+ logger.info(f"Document ready state: {doc_ready}")
932
+
933
+ # Font Awesomeが多い場合は追加待機
934
+ if fa_count > 50:
935
+ logger.info("Many Font Awesome icons detected, waiting additional time")
936
+ time.sleep(2)
937
+
938
+ # 6) コンテンツレンダリングのためのスクロール処理 - 同期的に実行
939
+ logger.info("Performing content rendering scroll...")
940
+ total_height = driver.execute_script("return Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);")
941
+ viewport_height = driver.execute_script("return window.innerHeight;")
942
+ scrolls_needed = max(1, total_height // viewport_height)
943
+
944
+ for i in range(scrolls_needed + 1):
945
+ scroll_pos = i * (viewport_height - 200) # オーバーラップさせる
946
+ driver.execute_script(f"window.scrollTo(0, {scroll_pos});")
947
+ time.sleep(0.2) # 短い待機
948
+
949
+ # トップに戻る
950
+ driver.execute_script("window.scrollTo(0, 0);")
951
+ time.sleep(0.5)
952
+ logger.info("Scroll rendering completed")
953
+
954
+ # 7) スクロールバーを非表示に
955
+ driver.execute_script("""
956
+ document.documentElement.style.overflow = 'hidden';
957
+ document.body.style.overflow = 'hidden';
958
+ """)
959
+ logger.info("Scrollbars hidden")
960
+
961
+ # 8) ページの寸法を取得
962
+ dimensions = driver.execute_script("""
963
+ return {
964
+ width: Math.max(
965
+ document.documentElement.scrollWidth,
966
+ document.documentElement.offsetWidth,
967
+ document.documentElement.clientWidth,
968
+ document.body ? document.body.scrollWidth : 0,
969
+ document.body ? document.body.offsetWidth : 0,
970
+ document.body ? document.body.clientWidth : 0
971
+ ),
972
+ height: Math.max(
973
+ document.documentElement.scrollHeight,
974
+ document.documentElement.offsetHeight,
975
+ document.documentElement.clientHeight,
976
+ document.body ? document.body.scrollHeight : 0,
977
+ document.body ? document.body.offsetHeight : 0,
978
+ document.body ? document.body.clientHeight : 0
979
+ )
980
+ };
981
+ """)
982
+ scroll_width = dimensions['width']
983
+ scroll_height = dimensions['height']
984
+ logger.info(f"Detected dimensions: width={scroll_width}, height={scroll_height}")
985
+
986
+ # 再検証 - 短いスクロールで再確認
987
+ driver.execute_script("window.scrollTo(0, document.body.scrollHeight);")
988
+ time.sleep(0.5)
989
+ driver.execute_script("window.scrollTo(0, 0);")
990
+ time.sleep(0.5)
991
+
992
+ dimensions_after = driver.execute_script("return {height: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight)};")
993
+ scroll_height = max(scroll_height, dimensions_after['height'])
994
+ logger.info(f"After scroll check, height={scroll_height}")
995
+
996
+ # 最小/最大値の設定
997
+ scroll_width = max(scroll_width, 100)
998
+ scroll_height = max(scroll_height, 100)
999
+ scroll_width = min(scroll_width, 2000)
1000
+ scroll_height = min(scroll_height, 4000)
1001
+
1002
+ # 9) レイアウト安定化のための単純な待機 - タイムアウト回避
1003
+ logger.info("Waiting for layout stabilization...")
1004
+ time.sleep(2)
1005
+
1006
+ # 10) 高さに余白を追加
1007
+ adjusted_height = int(scroll_height * (1 + extension_percentage / 100.0))
1008
+ adjusted_height = max(adjusted_height, scroll_height, 100)
1009
+ logger.info(f"Adjusted height calculated: {adjusted_height} (extension: {extension_percentage}%)")
1010
+
1011
+ # 11) ウィンドウサイズを調整
1012
+ adjusted_width = scroll_width
1013
+ logger.info(f"Resizing window to: width={adjusted_width}, height={adjusted_height}")
1014
+ driver.set_window_size(adjusted_width, adjusted_height)
1015
+ time.sleep(1)
1016
+
1017
+ # リソース状態を確認 - 同期的スクリプト
1018
+ resource_state = driver.execute_script("""
1019
+ return {
1020
+ readyState: document.readyState,
1021
+ resourcesComplete: !document.querySelector('img:not([complete])') &&
1022
+ !document.querySelector('link[rel="stylesheet"]:not([loaded])')
1023
+ };
1024
+ """)
1025
+ logger.info(f"Resource state: {resource_state}")
1026
+
1027
+ if resource_state['readyState'] != 'complete':
1028
+ logger.info("Document still loading, waiting additional time...")
1029
+ time.sleep(1)
1030
+
1031
+ # トップにスクロール
1032
+ driver.execute_script("window.scrollTo(0, 0);")
1033
+ time.sleep(0.5)
1034
+ logger.info("Scrolled to top.")
1035
+
1036
+ # 12) スクリーンショット取得
1037
+ logger.info("Taking screenshot...")
1038
+ png = driver.get_screenshot_as_png()
1039
+ logger.info("Screenshot taken successfully.")
1040
+
1041
+ # PIL画像に変換
1042
+ img = Image.open(BytesIO(png))
1043
+ logger.info(f"Screenshot dimensions: {img.width}x{img.height}")
1044
+
1045
+ # 余白トリミング
1046
+ if trim_whitespace:
1047
+ img = trim_image_whitespace(img, threshold=248, padding=20)
1048
+ logger.info(f"Trimmed dimensions: {img.width}x{img.height}")
1049
+
1050
+ return img
1051
+
1052
+ except Exception as e:
1053
+ logger.error(f"Error during screenshot generation: {e}", exc_info=True)
1054
+ # Return a small black image on error
1055
+ return Image.new('RGB', (1, 1), color=(0, 0, 0))
1056
+ finally:
1057
+ logger.info("Cleaning up...")
1058
+ if driver:
1059
+ try:
1060
+ driver.quit()
1061
+ logger.info("WebDriver quit successfully.")
1062
+ except Exception as e:
1063
+ logger.error(f"Error quitting WebDriver: {e}", exc_info=True)
1064
+ if tmp_path and os.path.exists(tmp_path):
1065
+ try:
1066
+ os.remove(tmp_path)
1067
+ logger.info(f"Temporary file {tmp_path} removed.")
1068
+ except Exception as e:
1069
+ logger.error(f"Error removing temporary file {tmp_path}: {e}", exc_info=True)
1070
+
1071
+
1072
+ # --- Geminiを使った新しい関数 ---
1073
+ def text_to_screenshot(text: str, extension_percentage: float, temperature: float = 0.3,
1074
+ trim_whitespace: bool = True, style: str = "standard") -> Image.Image:
1075
+ """テキストをGemini APIでHTMLに変換し、スクリーンショットを生成する統合関数"""
1076
+ try:
1077
+ # 1. テキストからHTMLを生成(温度パラメータとスタイルも渡す)
1078
+ html_code = generate_html_from_text(text, temperature, style)
1079
+
1080
+ # 2. HTMLからスクリーンショットを生成
1081
+ return render_fullpage_screenshot(html_code, extension_percentage, trim_whitespace)
1082
+ except Exception as e:
1083
+ logger.error(f"テキストからスクリーンショット生成中にエラーが発生: {e}", exc_info=True)
1084
+ return Image.new('RGB', (1, 1), color=(0, 0, 0)) # エラー時は黒画像
1085
+
1086
+ # --- FastAPI Setup ---
1087
+ app = FastAPI()
1088
+
1089
+ # CORS設定を追加
1090
+ app.add_middleware(
1091
+ CORSMiddleware,
1092
+ allow_origins=["*"],
1093
+ allow_credentials=True,
1094
+ allow_methods=["*"],
1095
+ allow_headers=["*"],
1096
+ )
1097
+
1098
+ # 静的ファイルのサービング設定
1099
+ # Gradioのディレクトリを探索してアセットを見つける
1100
+ gradio_dir = os.path.dirname(gr.__file__)
1101
+ logger.info(f"Gradio version: {gr.__version__}")
1102
+ logger.info(f"Gradio directory: {gradio_dir}")
1103
+
1104
+ # 基本的な静的ファイルディレクトリをマウント
1105
+ static_dir = os.path.join(gradio_dir, "templates", "frontend", "static")
1106
+ if os.path.exists(static_dir):
1107
+ logger.info(f"Mounting static directory: {static_dir}")
1108
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
1109
+
1110
+ # _appディレクトリを探す(新しいSvelteKitベースのフロントエンド用)
1111
+ app_dir = os.path.join(gradio_dir, "templates", "frontend", "_app")
1112
+ if os.path.exists(app_dir):
1113
+ logger.info(f"Mounting _app directory: {app_dir}")
1114
+ app.mount("/_app", StaticFiles(directory=app_dir), name="_app")
1115
+
1116
+ # assetsディレクトリを探す
1117
+ assets_dir = os.path.join(gradio_dir, "templates", "frontend", "assets")
1118
+ if os.path.exists(assets_dir):
1119
+ logger.info(f"Mounting assets directory: {assets_dir}")
1120
+ app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
1121
+
1122
+ # cdnディレクトリがあれば追加
1123
+ cdn_dir = os.path.join(gradio_dir, "templates", "cdn")
1124
+ if os.path.exists(cdn_dir):
1125
+ logger.info(f"Mounting cdn directory: {cdn_dir}")
1126
+ app.mount("/cdn", StaticFiles(directory=cdn_dir), name="cdn")
1127
+
1128
+
1129
+ # API Endpoint for screenshot generation
1130
+ @app.post("/api/screenshot",
1131
+ response_class=StreamingResponse,
1132
+ tags=["Screenshot"],
1133
+ summary="Render HTML to Full Page Screenshot",
1134
+ 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.")
1135
+ async def api_render_screenshot(request: ScreenshotRequest):
1136
+ """
1137
+ API endpoint to render HTML and return a screenshot.
1138
+ """
1139
+ try:
1140
+ logger.info(f"API request received. Extension: {request.extension_percentage}%")
1141
+ # Run the blocking Selenium code in a separate thread (FastAPI handles this)
1142
+ pil_image = render_fullpage_screenshot(
1143
+ request.html_code,
1144
+ request.extension_percentage,
1145
+ request.trim_whitespace
1146
+ )
1147
+
1148
+ if pil_image.size == (1, 1):
1149
+ logger.error("Screenshot generation failed, returning 1x1 error image.")
1150
+ # Optionally return a proper error response instead of 1x1 image
1151
+ # raise HTTPException(status_code=500, detail="Failed to generate screenshot")
1152
+
1153
+ # Convert PIL Image to PNG bytes
1154
+ img_byte_arr = BytesIO()
1155
+ pil_image.save(img_byte_arr, format='PNG')
1156
+ img_byte_arr.seek(0) # Go to the start of the BytesIO buffer
1157
+
1158
+ logger.info("Returning screenshot as PNG stream.")
1159
+ return StreamingResponse(img_byte_arr, media_type="image/png")
1160
+
1161
+ except Exception as e:
1162
+ logger.error(f"API Error: {e}", exc_info=True)
1163
+ raise HTTPException(status_code=500, detail=f"Internal Server Error: {e}")
1164
+
1165
+ # --- 新しいGemini API連携エンドポイント ---
1166
+ @app.post("/api/text-to-screenshot",
1167
+ response_class=StreamingResponse,
1168
+ tags=["Screenshot", "Gemini"],
1169
+ summary="テキストからインフォグラフィックを生成",
1170
+ description="テキストをGemini APIを使ってHTMLインフォグラフィックに変換し、スクリーンショットとして返します。")
1171
+ async def api_text_to_screenshot(request: GeminiRequest):
1172
+ """
1173
+ テキストからHTMLインフォグラフィックを生成してスクリーンショットを返すAPIエンドポイント
1174
+ """
1175
+ try:
1176
+ logger.info(f"テキスト→スクリーンショットAPIリクエスト受信。テキスト長さ: {len(request.text)}, "
1177
+ f"拡張率: {request.extension_percentage}%, 温度: {request.temperature}, "
1178
+ f"スタイル: {request.style}")
1179
+
1180
+ # テキストからHTMLを生成してスクリーンショットを作成(温度パラメータとスタイルも渡す)
1181
+ pil_image = text_to_screenshot(
1182
+ request.text,
1183
+ request.extension_percentage,
1184
+ request.temperature,
1185
+ request.trim_whitespace,
1186
+ request.style
1187
+ )
1188
+
1189
+ if pil_image.size == (1, 1):
1190
+ logger.error("スクリーンショット生成に失敗しました。1x1エラー画像を返します。")
1191
+ # raise HTTPException(status_code=500, detail="スクリーンショット生成に失敗しました")
1192
+
1193
+
1194
+ # PIL画像をPNGバイトに変換
1195
+ img_byte_arr = BytesIO()
1196
+ pil_image.save(img_byte_arr, format='PNG')
1197
+ img_byte_arr.seek(0) # BytesIOバッファの先頭に戻る
1198
+
1199
+ logger.info("スクリーンショットをPNGストリームとして返します。")
1200
+ return StreamingResponse(img_byte_arr, media_type="image/png")
1201
+
1202
+ except Exception as e:
1203
+ logger.error(f"API Error: {e}", exc_info=True)
1204
+ raise HTTPException(status_code=500, detail=f"Internal Server Error: {e}")
1205
+
1206
+ # --- Gradio Interface Definition ---
1207
+ # 入力モードの選択用Radioコンポーネント
1208
+ def process_input(input_mode, input_text, extension_percentage, temperature, trim_whitespace, style):
1209
+ """入力モードに応じて適切な処理を行う"""
1210
+ if input_mode == "HTML入力":
1211
+ # HTMLモードの場合は既存の処理(スタイルは使わない)
1212
+ return render_fullpage_screenshot(input_text, extension_percentage, trim_whitespace)
1213
+ else:
1214
+ # テキスト入力モードの場合はGemini APIを使用
1215
+ return text_to_screenshot(input_text, extension_percentage, temperature, trim_whitespace, style)
1216
+
1217
+ # Gradio UIの定義
1218
+ with gr.Blocks(title="Full Page Screenshot (テキスト変換対応)", theme=gr.themes.Base()) as iface:
1219
+ gr.Markdown("# HTMLビューア & テキスト→インフォグラフィック変換")
1220
+ gr.Markdown("HTMLコードをレンダリングするか、テキストをGemini APIでインフォグラフィックに変換して画像として取得します。")
1221
+
1222
+ with gr.Row():
1223
+ input_mode = gr.Radio(
1224
+ ["HTML入力", "テキスト入力"],
1225
+ label="入力モード",
1226
+ value="HTML入力"
1227
+ )
1228
+
1229
+ # 共用のテキストボックス
1230
+ input_text = gr.Textbox(
1231
+ lines=15,
1232
+ label="入力",
1233
+ placeholder="HTMLコードまたはテキストを入力してください。入力モードに応じて処理されます。"
1234
+ )
1235
+
1236
+ with gr.Row():
1237
+ with gr.Column(scale=1):
1238
+ # スタイル選択ドロップダウン
1239
+ style_dropdown = gr.Dropdown(
1240
+ choices=["standard", "cute", "resort", "cool", "dental"],
1241
+ value="standard",
1242
+ label="デザインスタイル",
1243
+ info="テキスト→HTML変換時のデザインテーマを選択します",
1244
+ visible=False # テキスト入力モードの時だけ表示
1245
  )
1246
+
1247
  with gr.Column(scale=2):
1248
  extension_percentage = gr.Slider(
1249
+ minimum=0,
1250
+ maximum=30,
1251
+ step=1.0,
1252
+ value=10, # デフォルト値10%
1253
+ label="上下高さ拡張率(%)"
1254
  )
1255
+
1256
+ # 温度調整スライダー(テキストモード時のみ表示)
1257
+ temperature = gr.Slider(
1258
+ minimum=0.0,
1259
+ maximum=1.0,
1260
+ step=0.1,
1261
+ value=0.5, # デフォルト値を0.5に設定
1262
+ label="生成時の温度(低い=一貫性高、高い=創造性高)",
1263
+ visible=False # 最初は非表示
1264
  )
1265
 
1266
+ # 余白トリミングオプション
1267
+ trim_whitespace = gr.Checkbox(
1268
+ label="余白を自動トリミング",
1269
+ value=True,
1270
+ info="生成される画像から余分な空白領域を自動的に削除します"
1271
+ )
1272
+
1273
+ submit_btn = gr.Button("生成")
1274
+ output_image = gr.Image(type="pil", label="ページ全体のスクリーンショット")
1275
 
1276
+ # 入力モード変更時のイベント処理(テキストモード時のみ温度スライダーとスタイルドロップダウンを表示)
1277
  def update_controls_visibility(mode):
1278
+ # Gradio 4.x用のアップデート方法
1279
+ is_text_mode = mode == "テキスト入力"
1280
+ return [
1281
+ gr.update(visible=is_text_mode), # temperature
1282
+ gr.update(visible=is_text_mode), # style_dropdown
1283
+ ]
1284
 
1285
  input_mode.change(
1286
  fn=update_controls_visibility,
1287
  inputs=input_mode,
1288
+ outputs=[temperature, style_dropdown]
1289
  )
1290
 
1291
+ # 生成ボタンクリック時のイベント処理
1292
  submit_btn.click(
1293
  fn=process_input,
1294
  inputs=[input_mode, input_text, extension_percentage, temperature, trim_whitespace, style_dropdown],
1295
  outputs=output_image
1296
  )
1297
 
1298
+ # 環境変数情報を表示
1299
+ gemini_model = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
1300
+ gr.Markdown(f"""
1301
+ ## APIエンドポイント
1302
+ - `/api/screenshot` - HTMLコードからスクリーンショットを生成
1303
+ - `/api/text-to-screenshot` - テキストからインフォグラフィックスクリーンショットを生成
1304
+
1305
+ ## 設定情報
1306
+ - 使用モデル: {gemini_model} (環境変数 GEMINI_MODEL で変更可能)
1307
+ - 対応スタイル: standard, cute, resort, cool, dental
1308
+ """)
 
 
1309
 
1310
  # --- Mount Gradio App onto FastAPI ---
1311
+ app = gr.mount_gradio_app(app, iface, path="/")
 
 
 
 
 
 
 
 
 
1312
 
1313
  # --- Run with Uvicorn (for local testing) ---
1314
  if __name__ == "__main__":
1315
  import uvicorn
1316
+ logger.info("Starting Uvicorn server for local development...")
1317
+ uvicorn.run(app, host="0.0.0.0", port=7860)