SOY NV AI commited on
Commit
db693d8
ยท
1 Parent(s): 16db560

Add Character Creation and Webtoon Line Art Challenge features, and enhance API integration

Browse files
app/clients/comfyui_client.py CHANGED
@@ -90,3 +90,4 @@ class ComfyUIClient:
90
 
91
 
92
 
 
 
90
 
91
 
92
 
93
+
app/database.py CHANGED
@@ -1155,4 +1155,3 @@ class WebtoonLineArtJob(db.Model):
1155
  'created_at': self.created_at.isoformat() if self.created_at else None,
1156
  'updated_at': self.updated_at.isoformat() if self.updated_at else None,
1157
  }
1158
-
 
1155
  'created_at': self.created_at.isoformat() if self.created_at else None,
1156
  'updated_at': self.updated_at.isoformat() if self.updated_at else None,
1157
  }
 
app/gemini_client.py CHANGED
@@ -343,99 +343,76 @@ class GeminiClient:
343
  pass
344
 
345
  # REST API ๋ฒ ์ด์Šค URL (v1beta ์šฐ์„  ์‚ฌ์šฉ)
346
- rest_base_url = 'https://generativelanguage.googleapis.com/v1beta'
347
- url = f"{rest_base_url}/models/{model_name_clean}:generateContent"
 
 
348
 
349
- logger.info(f"[Gemini] - API ๋ฒ„์ „: v1beta (๊ธฐ๋ณธ)")
350
- logger.info(f"[Gemini] - ์›๋ณธ ๋ชจ๋ธ ์ด๋ฆ„: {model_name}")
351
- logger.info(f"[Gemini] - ์ •๊ทœํ™”๋œ ๋ชจ๋ธ ์ด๋ฆ„: {model_name_clean}")
352
- logger.info(f"[Gemini] - ์ „์ฒด URL: {url}")
353
 
354
- # REST API ์š”์ฒญ ๋ณธ๋ฌธ ๊ตฌ์„ฑ
355
- request_body = {
356
- "contents": [{
357
- "parts": [{
358
- "text": prompt
359
- }]
360
- }],
361
- "generationConfig": generation_config
362
- }
363
-
364
- # REST API ํ—ค๋” (API ํ‚ค๋ฅผ ํ—ค๋”๋กœ ์ „๋‹ฌ ์‹œ๋„)
365
- headers = {
366
- "Content-Type": "application/json",
367
- "x-goog-api-key": api_key_clean
368
- }
369
-
370
- logger.info(f"[Gemini] REST API ํ˜ธ์ถœ ์ „์†ก ์ค‘...")
371
- # ... ๋กœ๊น… ์ƒ๋žต ...
372
-
373
- # REST API ํ˜ธ์ถœ (API ํ‚ค๋ฅผ ํ—ค๋”์™€ params ์–‘์ชฝ์œผ๋กœ ์ „๋‹ฌ ์‹œ๋„)
374
- api_params = {"key": api_key_clean}
375
-
376
- rest_response = requests.post(
377
- url,
378
- headers=headers,
379
- json=request_body,
380
- params=api_params,
381
- timeout=timeout_seconds
382
- )
383
-
384
- # ... (API ํ‚ค ๋งˆ์Šคํ‚น ๋กœ๊น… ๋“ฑ ๊ธฐ์กด ๋กœ์ง ์œ ์ง€) ...
385
-
386
- logger.info(f"[Gemini] REST API ์‘๋‹ต ์ƒํƒœ ์ฝ”๋“œ: {rest_response.status_code}")
387
-
388
- # ์‘๋‹ต ๋ณธ๋ฌธ ํ™•์ธ
389
- response_has_error = False
390
- try:
391
- response_data_check = rest_response.json()
392
- if 'error' in response_data_check:
393
- response_has_error = True
394
- error_info = response_data_check['error']
395
- error_code = error_info.get('code', rest_response.status_code)
396
- error_message = error_info.get('message', '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜')
397
- logger.error(f"[Gemini] ์‘๋‹ต ๋ณธ๋ฌธ์— ์—๋Ÿฌ ๊ฐ์ง€: code={error_code}, message={error_message}")
398
 
399
- # ์—๋Ÿฌ ์ฝ”๋“œ์— ๋”ฐ๋ผ ์ฒ˜๋ฆฌ (404 ์žฌ์‹œ๋„, 429 ์—๋Ÿฌ ๋ฉ”์‹œ์ง€ ์ƒ์„ฑ ๋“ฑ ๊ธฐ์กด ๋กœ์ง ์œ ๏ฟฝ๏ฟฝ)
400
- if error_code == 404:
401
- # ... (404 ์ฒ˜๋ฆฌ ๋กœ์ง) ...
402
- logger.warning(f"[Gemini] v1beta์—์„œ ๋ชจ๋ธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ, v1์œผ๋กœ ์žฌ์‹œ๋„...")
403
- rest_base_url_v1 = 'https://generativelanguage.googleapis.com/v1'
404
- url_v1 = f"{rest_base_url_v1}/models/{model_name_clean}:generateContent"
405
-
406
- rest_response = requests.post(
407
- url_v1,
408
- headers=headers,
409
- json=request_body,
410
- params=api_params,
411
- timeout=timeout_seconds
412
- )
413
- # ... (์ดํ›„ v1 ์‘๋‹ต ์ฒ˜๋ฆฌ ๋ฐ Fallback ๋กœ์ง์€ ๋„ˆ๋ฌด ๊ธธ์–ด ์ƒ๋žตํ•˜๋‚˜ ์›๋ณธ ์œ ์ง€ ํ•„์š”) ...
414
- # ํŽธ์˜์ƒ ์›๋ณธ์˜ 404/429 ์ฒ˜๋ฆฌ ๋กœ์ง์€ ๋™์ผํ•˜๊ฒŒ ์ž‘๋™ํ•œ๋‹ค๊ณ  ๊ฐ€์ •ํ•˜๊ณ  ์ถ•์•ฝํ•˜์ง€ ์•Š์Œ
415
- # ์‹ค์ œ ๊ตฌํ˜„ ์‹œ์—๋Š” ์›๋ณธ ํŒŒ์ผ ๋‚ด์šฉ์„ ๊ทธ๋Œ€๋กœ ๋ณต์‚ฌํ•ด์•ผ ํ•จ.
416
- # ์—ฌ๊ธฐ์„œ๋Š” ์ง€๋ฉด ๊ด€๊ณ„์ƒ ํ•ต์‹ฌ ๋กœ์ง๋งŒ ๋ณ€๊ฒฝํ•˜๊ณ  ๋‚˜๋จธ์ง€๋Š” ... ์ฒ˜๋ฆฌ ํ•˜์˜€์œผ๋‚˜
417
- # ์‹ค์ œ write ์‹œ์—๋Š” ์ „์ฒด ๋‚ด์šฉ์„ ๋„ฃ์–ด์•ผ ํ•จ.
418
-
419
- # (์ค‘๋žต๋œ 404/429 ์ฒ˜๋ฆฌ ๋กœ์ง)
420
- pass # ์‹ค์ œ ์ฝ”๋“œ์—์„œ๋Š” ์›๋ณธ ๋‚ด์šฉ ํฌํ•จ
421
- elif error_code == 429:
422
- # ... (429 ์ฒ˜๋ฆฌ ๋กœ์ง) ...
423
- raise Exception(f"Gemini API ํ• ๋‹น๋Ÿ‰ ์ดˆ๊ณผ (429): {error_message}")
424
  else:
425
- error_text = json.dumps(error_info)
426
- raise Exception(f"REST API ์˜ค๋ฅ˜ {error_code}: {error_text}")
427
- except ValueError:
428
- pass
429
-
430
- if rest_response.status_code != 200 and not response_has_error:
431
- # ... (์—๋Ÿฌ ์ฒ˜๋ฆฌ) ...
432
- raise Exception(f"REST API ์˜ค๋ฅ˜ {rest_response.status_code}")
433
-
434
- if response_has_error:
435
- raise Exception(f"REST API ์˜ค๋ฅ˜: ์‘๋‹ต์— ์—๋Ÿฌ๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.")
 
 
 
 
436
 
437
  response_data = rest_response.json()
438
-
439
  # ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ๋“ฑ ์ถ”์ถœ
440
  input_tokens = None
441
  output_tokens = None
@@ -618,9 +595,12 @@ class GeminiClient:
618
  }
619
 
620
  if is_image_gen_model:
621
- # ์ด๋ฏธ์ง€ ์ƒ์„ฑ ๋ชจ๋ธ์šฉ ์ตœ์ ํ™” ์„ค์ • (์ถฉ๋Œ ๊ฐ€๋Šฅ ํŒŒ๋ผ๋ฏธํ„ฐ ์ œ๊ฑฐ)
622
- config_params['max_output_tokens'] = 1024
623
- # ์ฐธ๊ณ : ์ผ๋ถ€ ์ด๋ฏธ์ง€ ๋ชจ๋ธ์€ temperature ๋“ฑ์„ ์ง€์›ํ•˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์–ด ์ตœ์†Œํ™”ํ•จ
 
 
 
624
  else:
625
  # ์ผ๋ฐ˜ ํ…์ŠคํŠธ ๋ชจ๋ธ์šฉ ์„ค์ •
626
  config_params.update({
@@ -784,12 +764,12 @@ class GeminiClient:
784
  generated_images.append(part['inline_data'].get('data'))
785
 
786
  # ์‘๋‹ต์— ์ด๋ฏธ์ง€๋Š” ์—†์ง€๋งŒ ํ…์ŠคํŠธ๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ, ํ˜น์‹œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€์ธ์ง€ ํ™•์ธ
787
- if not generated_images and response_text:
788
  if any(kw in response_text.lower() for kw in ["error", "cannot", "sorry", "unable", "failed"]):
789
  logger.warning(f"[Gemini Multimodal] AI๊ฐ€ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ๊ฑฐ๋ถ€ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ธ ๊ฒƒ์œผ๋กœ ๋ณด์ž„: {response_text[:200]}")
790
 
791
- # ์‘๋‹ต์ด ๋น„์–ด์žˆ๊ฑฐ๋‚˜ ์ด๋ฏธ์ง€๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ์ƒ์„ธ ๋กœ๊น…
792
- if not generated_images:
793
  try:
794
  # ์ฐจ๋‹จ ์‚ฌ์œ (Safety) ํ™•์ธ
795
  block_reason = "No blocks"
 
343
  pass
344
 
345
  # REST API ๋ฒ ์ด์Šค URL (v1beta ์šฐ์„  ์‚ฌ์šฉ)
346
+ rest_base_urls = [
347
+ 'https://generativelanguage.googleapis.com/v1beta',
348
+ 'https://generativelanguage.googleapis.com/v1'
349
+ ]
350
 
351
+ rest_response = None
352
+ success = False
 
 
353
 
354
+ for base_url in rest_base_urls:
355
+ url = f"{base_url}/models/{model_name_clean}:generateContent"
356
+ logger.info(f"[Gemini] - API ํ˜ธ์ถœ ์‹œ๋„: {url}")
357
+
358
+ # REST API ์š”์ฒญ ๋ณธ๋ฌธ ๊ตฌ์„ฑ
359
+ request_body = {
360
+ "contents": [{
361
+ "parts": [{
362
+ "text": prompt
363
+ }]
364
+ }],
365
+ "generationConfig": generation_config
366
+ }
367
+
368
+ # REST API ํ—ค๋”
369
+ headers = {
370
+ "Content-Type": "application/json",
371
+ "x-goog-api-key": api_key_clean
372
+ }
373
+
374
+ api_params = {"key": api_key_clean}
375
+
376
+ try:
377
+ rest_response = requests.post(
378
+ url,
379
+ headers=headers,
380
+ json=request_body,
381
+ params=api_params,
382
+ timeout=timeout_seconds
383
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
 
385
+ logger.info(f"[Gemini] REST API ์‘๋‹ต ์ƒํƒœ ์ฝ”๋“œ ({base_url}): {rest_response.status_code}")
386
+
387
+ if rest_response.status_code == 200:
388
+ response_data = rest_response.json()
389
+ if 'error' not in response_data:
390
+ success = True
391
+ break
392
+ else:
393
+ logger.warning(f"[Gemini] API ์‘๋‹ต์— ์—๋Ÿฌ ํฌํ•จ: {response_data['error']}")
394
+ elif rest_response.status_code == 404:
395
+ logger.warning(f"[Gemini] ๋ชจ๋ธ์„ ์ฐพ์„ ์ˆ˜ ์—†์Œ (404): {url}")
396
+ continue
 
 
 
 
 
 
 
 
 
 
 
 
 
397
  else:
398
+ logger.warning(f"[Gemini] API ํ˜ธ์ถœ ์‹คํŒจ ({rest_response.status_code}): {rest_response.text[:200]}")
399
+ except Exception as e:
400
+ logger.error(f"[Gemini] REST API ํ˜ธ์ถœ ์ค‘ ์˜ˆ์™ธ ๋ฐœ์ƒ: {e}")
401
+ continue
402
+
403
+ if not success:
404
+ if rest_response is not None:
405
+ try:
406
+ error_data = rest_response.json()
407
+ error_msg = error_data.get('error', {}).get('message', '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜')
408
+ raise Exception(f"Gemini API ์˜ค๋ฅ˜ ({rest_response.status_code}): {error_msg}")
409
+ except:
410
+ raise Exception(f"Gemini API ์˜ค๋ฅ˜ ({rest_response.status_code})")
411
+ else:
412
+ raise Exception("Gemini API ํ˜ธ์ถœ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.")
413
 
414
  response_data = rest_response.json()
415
+
416
  # ํ† ํฐ ์‚ฌ์šฉ๋Ÿ‰ ๋“ฑ ์ถ”์ถœ
417
  input_tokens = None
418
  output_tokens = None
 
595
  }
596
 
597
  if is_image_gen_model:
598
+ # ์ด๋ฏธ์ง€ ์ƒ์„ฑ ๋ชจ๋ธ์šฉ ์ตœ์ ํ™” ์„ค์ •
599
+ # imagen ๊ณ„์—ด์€ ํ…์ŠคํŠธ ์ถœ๋ ฅ์ด ์—†์œผ๋ฏ€๋กœ ์ž‘๊ฒŒ ์œ ์ง€, gemini ๊ณ„์—ด(๋ฉ€ํ‹ฐ๋ชจ๋‹ฌ)์€ ํ…์ŠคํŠธ ๋ถ„์„๋„ ํ•˜๋ฏ€๋กœ ํฌ๊ฒŒ ํ—ˆ์šฉ
600
+ if "imagen" in model_name_clean.lower():
601
+ config_params['max_output_tokens'] = 1024
602
+ else:
603
+ config_params['max_output_tokens'] = 4096 # ๋ฉ€ํ‹ฐ๋ชจ๋‹ฌ ๋ชจ๋ธ์˜ ํ…์ŠคํŠธ ์‘๋‹ต ํ—ˆ์šฉ๋Ÿ‰ ํ™•๋Œ€
604
  else:
605
  # ์ผ๋ฐ˜ ํ…์ŠคํŠธ ๋ชจ๋ธ์šฉ ์„ค์ •
606
  config_params.update({
 
764
  generated_images.append(part['inline_data'].get('data'))
765
 
766
  # ์‘๋‹ต์— ์ด๋ฏธ์ง€๋Š” ์—†์ง€๋งŒ ํ…์ŠคํŠธ๋งŒ ์žˆ๋Š” ๊ฒฝ์šฐ, ํ˜น์‹œ ์—๋Ÿฌ ๋ฉ”์‹œ์ง€์ธ์ง€ ํ™•์ธ
767
+ if is_image_gen_model and not generated_images and response_text:
768
  if any(kw in response_text.lower() for kw in ["error", "cannot", "sorry", "unable", "failed"]):
769
  logger.warning(f"[Gemini Multimodal] AI๊ฐ€ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ๊ฑฐ๋ถ€ ๋ฉ”์‹œ์ง€๋ฅผ ๋ณด๋‚ธ ๊ฒƒ์œผ๋กœ ๋ณด์ž„: {response_text[:200]}")
770
 
771
+ # ์‘๋‹ต์ด ๋น„์–ด์žˆ๊ฑฐ๋‚˜ ์ด๋ฏธ์ง€๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ์ƒ์„ธ ๋กœ๊น… (์ด๋ฏธ์ง€ ๋ชจ๋ธ์ธ ๊ฒฝ์šฐ๋งŒ)
772
+ if is_image_gen_model and not generated_images:
773
  try:
774
  # ์ฐจ๋‹จ ์‚ฌ์œ (Safety) ํ™•์ธ
775
  block_reason = "No blocks"
app/models/webtoon_conti.py CHANGED
@@ -11,6 +11,8 @@ class WebtoonConti(db.Model):
11
  episode_id = db.Column(db.Integer, db.ForeignKey('episode_analysis.id'), nullable=True)
12
  title = db.Column(db.String(255), nullable=False)
13
  description = db.Column(db.Text, nullable=True)
 
 
14
  created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
15
  updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
16
 
@@ -28,6 +30,8 @@ class WebtoonConti(db.Model):
28
  'episode_id': self.episode_id,
29
  'title': self.title,
30
  'description': self.description,
 
 
31
  'created_at': self.created_at.isoformat() if self.created_at else None,
32
  'updated_at': self.updated_at.isoformat() if self.updated_at else None,
33
  'panel_count': len(self.panels) if self.panels else 0
 
11
  episode_id = db.Column(db.Integer, db.ForeignKey('episode_analysis.id'), nullable=True)
12
  title = db.Column(db.String(255), nullable=False)
13
  description = db.Column(db.Text, nullable=True)
14
+ analysis_prompt = db.Column(db.Text, nullable=True) # ๋ถ„์„ ์ฐธ์กฐ ํ”„๋กฌํ”„ํŠธ ์ถ”๊ฐ€
15
+ gen_prompt = db.Column(db.Text, nullable=True) # ๊ทธ๋ฆผ ์ฝ˜ํ‹ฐ์šฉ ํ”„๋กฌํ”„ํŠธ ์ถ”๊ฐ€
16
  created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
17
  updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
18
 
 
30
  'episode_id': self.episode_id,
31
  'title': self.title,
32
  'description': self.description,
33
+ 'analysis_prompt': self.analysis_prompt,
34
+ 'gen_prompt': self.gen_prompt,
35
  'created_at': self.created_at.isoformat() if self.created_at else None,
36
  'updated_at': self.updated_at.isoformat() if self.updated_at else None,
37
  'panel_count': len(self.panels) if self.panels else 0
app/prompts/webtoon_conti.py CHANGED
@@ -75,3 +75,4 @@ def get_webtoon_conti_prompt(
75
 
76
 
77
 
 
 
75
 
76
 
77
 
78
+
app/routers/creation.py CHANGED
@@ -6,6 +6,7 @@ import os
6
  import asyncio
7
  import re
8
  import json
 
9
  from sqlalchemy import case
10
  from app.database import db, NovelProject, UploadedFile, ParentChunk, DocumentChunk, GraphEntity, GraphRelationship, GraphEvent, NovelProjectFact, StyleAnalysis, SystemConfig
11
  from app.routes import inject_admin_menu as shared_inject_admin_menu
@@ -70,6 +71,215 @@ def get_deps(project_id: str, user_id: int, style_content: str = None) -> NovelW
70
  style_content=style_content
71
  )
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  @creation_bp.route('/webnovel', methods=['GET'])
74
  @login_required
75
  def webnovel():
@@ -129,11 +339,16 @@ def upload_image():
129
  @creation_bp.route('/api/generate-image', methods=['POST'])
130
  @login_required
131
  def api_generate_image():
132
- """ํ…์ŠคํŠธ ํ”„๋กฌํ”„ํŠธ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์ด๋ฏธ์ง€ ์ƒ์„ฑ"""
133
  data = request.json
134
  prompt = data.get('prompt')
135
  model_name = data.get('model_name')
136
- image_url = data.get('image_url') # ์„ ํƒ๋œ ์ด๋ฏธ์ง€ URL
 
 
 
 
 
137
 
138
  if not prompt:
139
  return jsonify({'error': 'ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'}), 400
@@ -145,24 +360,32 @@ def api_generate_image():
145
 
146
  async def generate():
147
  # gemini_client๋ฅผ ํ†ตํ•ด ์ง์ ‘ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์š”์ฒญ
148
- # model_name์—์„œ 'gemini:' ์ ‘๋‘์‚ฌ ์ฒ˜๋ฆฌ
149
  target_model = model_name.replace('gemini:', '')
150
 
151
- system_instruction = "You are a professional artist. Please generate a high-quality image based on the user's detailed prompt. Output ONLY the image."
 
 
 
 
 
 
 
 
152
 
153
  contents = [system_instruction]
154
 
155
- # ์„ ํƒ๋œ ์ด๋ฏธ์ง€๊ฐ€ ์žˆ์œผ๋ฉด Gemini์— ์ „๋‹ฌ
156
- if image_url:
157
- try:
158
- # /uploads/ ์ ‘๋‘์–ด ์ œ๊ฑฐํ•˜์—ฌ ์ƒ๋Œ€ ๊ฒฝ๋กœ ํš๋“
159
- rel_path = image_url.replace('/uploads/', '')
160
- img_obj = await service.load_image(rel_path, max_size=1024)
161
- if img_obj:
162
- contents.append(img_obj)
163
- current_app.logger.info(f"Image attached to prompt: {rel_path}")
164
- except Exception as e:
165
- current_app.logger.error(f"Failed to load attached image: {e}")
 
166
 
167
  contents.append(f"Prompt: {prompt}")
168
 
 
6
  import asyncio
7
  import re
8
  import json
9
+ import requests
10
  from sqlalchemy import case
11
  from app.database import db, NovelProject, UploadedFile, ParentChunk, DocumentChunk, GraphEntity, GraphRelationship, GraphEvent, NovelProjectFact, StyleAnalysis, SystemConfig
12
  from app.routes import inject_admin_menu as shared_inject_admin_menu
 
71
  style_content=style_content
72
  )
73
 
74
+ @creation_bp.route('/api/character/global-prompt', methods=['GET'])
75
+ @login_required
76
+ def get_character_global_prompt():
77
+ """์บ๋ฆญํ„ฐ ์ œ์ž‘์šฉ ์ „์—ญ ๊ธฐ๋ณธ ํ”„๋กฌํ”„ํŠธ ์กฐํšŒ"""
78
+ return jsonify({
79
+ "prompt": SystemConfig.get_config('character_creation_default_prompt', '')
80
+ })
81
+
82
+ @creation_bp.route('/api/character/global-prompt', methods=['POST'])
83
+ @login_required
84
+ def save_character_global_prompt():
85
+ """์บ๋ฆญํ„ฐ ์ œ์ž‘์šฉ ์ „์—ญ ๊ธฐ๋ณธ ํ”„๋กฌํ”„ํŠธ ์ €์žฅ"""
86
+ data = request.get_json()
87
+ prompt = data.get('prompt')
88
+ if prompt is not None:
89
+ SystemConfig.set_config('character_creation_default_prompt', prompt)
90
+ return jsonify({"success": True, "message": "์บ๋ฆญํ„ฐ ์ œ์ž‘ ๊ธฐ๋ณธ ํ”„๋กฌํ”„ํŠธ๋กœ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."})
91
+
92
+ @creation_bp.route('/api/line-art-challenge/global-prompt', methods=['GET'])
93
+ @login_required
94
+ def get_line_art_challenge_global_prompt():
95
+ """์›นํˆฐ ์„ ํ™” ๋„์ „์šฉ ์ „์—ญ ๊ธฐ๋ณธ ํ”„๋กฌํ”„ํŠธ ์กฐํšŒ"""
96
+ return jsonify({
97
+ "prompt": SystemConfig.get_config('line_art_challenge_default_prompt', '')
98
+ })
99
+
100
+ @creation_bp.route('/api/line-art-challenge/global-prompt', methods=['POST'])
101
+ @login_required
102
+ def save_line_art_challenge_global_prompt():
103
+ """์›นํˆฐ ์„ ํ™” ๋„์ „์šฉ ์ „์—ญ ๊ธฐ๋ณธ ํ”„๋กฌํ”„ํŠธ ์ €์žฅ"""
104
+ data = request.get_json()
105
+ prompt = data.get('prompt')
106
+ if prompt is not None:
107
+ SystemConfig.set_config('line_art_challenge_default_prompt', prompt)
108
+ return jsonify({"success": True, "message": "์›นํˆฐ ์„ ํ™” ๋„์ „ ๊ธฐ๋ณธ ํ”„๋กฌํ”„ํŠธ๋กœ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."})
109
+
110
+ @creation_bp.route('/character', methods=['GET'])
111
+ @login_required
112
+ def character_creation():
113
+ """์บ๋ฆญํ„ฐ ์ œ์ž‘ ๋Œ€์‹œ๋ณด๋“œ ํŽ˜์ด์ง€"""
114
+ default_analysis_model = SystemConfig.get_config('default_analysis_model', '')
115
+ return render_template('character_creation.html', title="์บ๋ฆญํ„ฐ ์ œ์ž‘", default_analysis_model=default_analysis_model)
116
+
117
+ @creation_bp.route('/webtoon-line-art-challenge', methods=['GET'])
118
+ @login_required
119
+ def webtoon_line_art_challenge():
120
+ """์›นํˆฐ ์„ ํ™” ๋„์ „ ํŽ˜์ด์ง€"""
121
+ default_analysis_model = SystemConfig.get_config('default_analysis_model', '')
122
+ return render_template('webtoon_line_art_challenge.html', title="์›นํˆฐ ์„ ํ™” ๋„์ „", default_analysis_model=default_analysis_model)
123
+
124
+ @creation_bp.route('/api/character/extract-main', methods=['POST'])
125
+ @login_required
126
+ def extract_main_characters():
127
+ """์›น์†Œ์„ค์— ๋“ฑ๋ก๋œ ์บ๋ฆญํ„ฐ ๋ชฉ๋ก ๋ฐ ์ •๋ณด ๋ฐ˜ํ™˜ (Parent Chunk ์ปจํ…์ŠคํŠธ ํฌํ•จ)"""
128
+ data = request.get_json()
129
+ file_id = data.get('file_id')
130
+
131
+ if not file_id:
132
+ return jsonify({"error": "ํŒŒ์ผ ID๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."}), 400
133
+
134
+ try:
135
+ file_id = int(file_id)
136
+ except (ValueError, TypeError):
137
+ return jsonify({"error": "์œ ํšจํ•˜์ง€ ์•Š์€ ํŒŒ์ผ ID์ž…๋‹ˆ๋‹ค."}), 400
138
+
139
+ current_app.logger.info(f"Fetching character data for file_id: {file_id}")
140
+
141
+ # 1. DB์— ๋“ฑ๋ก๋œ ์บ๋ฆญํ„ฐ ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ (์ด๋ฏธ์ง€ ์ •๋ณด ํฌํ•จ)
142
+ from app.database import WebnovelCharacter
143
+ db_characters = WebnovelCharacter.query.filter_by(file_id=file_id).order_by(WebnovelCharacter.created_at.asc()).all()
144
+
145
+ # 2. Parent Chunk ์ •๋ณด ๊ฐ€์ ธ์˜ค๊ธฐ (์„ธ๊ณ„๊ด€, ์ค„๊ฑฐ๋ฆฌ ๋“ฑ ๋ฐฐ๊ฒฝ ์ •๋ณด์šฉ)
146
+ parent_chunk = ParentChunk.query.filter_by(file_id=file_id).first()
147
+ world_view = parent_chunk.world_view if parent_chunk else ""
148
+ story = parent_chunk.story if parent_chunk else ""
149
+ others = parent_chunk.others if parent_chunk else ""
150
+
151
+ chars = []
152
+
153
+ if db_characters:
154
+ # ๋“ฑ๋ก๋œ ์บ๋ฆญํ„ฐ๊ฐ€ ์žˆ์œผ๋ฉด ํ•ด๋‹น ๋ฐ์ดํ„ฐ ์‚ฌ์šฉ
155
+ for c in db_characters:
156
+ chars.append({
157
+ "id": c.id,
158
+ "name": c.name,
159
+ "description": c.description or "",
160
+ "image_path": c.image_path,
161
+ "images": [img.to_dict() for img in c.images] if hasattr(c, 'images') else []
162
+ })
163
+ else:
164
+ # ๋“ฑ๋ก๋œ ์บ๋ฆญํ„ฐ๊ฐ€ ์—†์œผ๋ฉด Parent Chunk์—์„œ ์ถ”์ถœ (๊ธฐ์กด ๋กœ์ง ํ•˜์œ„ ํ˜ธํ™˜์„ฑ ์œ ์ง€)
165
+ if parent_chunk and parent_chunk.characters:
166
+ char_text = parent_chunk.characters
167
+ lines = char_text.split('\n')
168
+ current_char = None
169
+ attr_keywords = ['์—ญํ• ', '์„ฑ๊ฒฉ', 'ํŠน์ง•', '์„ฑ๋ณ„', '๋‚˜์ด', '์™ธ๋ชจ', '๋Šฅ๋ ฅ', '์†Œ์†', '๋ฐฐ๊ฒฝ', '๋ชฉํ‘œ', '๋™๊ธฐ']
170
+
171
+ for line in lines:
172
+ line = line.strip()
173
+ if not line: continue
174
+ clean_line = re.sub(r'^[-*\d\.\s]+', '', line).strip()
175
+ if not clean_line: continue
176
+
177
+ if ':' in clean_line and len(clean_line.split(':', 1)[0]) < 15:
178
+ name_part, desc_part = clean_line.split(':', 1)
179
+ name_part = re.sub(r'[\[\](){}]', '', name_part).strip().replace('*', '')
180
+ is_attr_kw = any(name_part == kw for kw in attr_keywords)
181
+ if is_attr_kw:
182
+ if current_char: current_char["description"] += "\n" + clean_line
183
+ else:
184
+ current_char = {"name": name_part, "description": clean_line, "image_path": None, "images": []}
185
+ chars.append(current_char)
186
+ continue
187
+
188
+ is_attribute = any(clean_line.startswith(kw) for kw in attr_keywords)
189
+ if is_attribute:
190
+ if current_char: current_char["description"] += "\n" + clean_line
191
+ else:
192
+ if len(clean_line) > 15:
193
+ if current_char: current_char["description"] += "\n" + clean_line
194
+ continue
195
+ name = re.sub(r'[\[\](){}]', '', clean_line).strip().replace('*', '')
196
+ if name:
197
+ current_char = {"name": name, "description": clean_line, "image_path": None, "images": []}
198
+ chars.append(current_char)
199
+
200
+ return jsonify({
201
+ "characters": chars,
202
+ "world_view": world_view,
203
+ "story": story,
204
+ "others": others
205
+ })
206
+
207
+ @creation_bp.route('/api/character/analyze', methods=['POST'])
208
+ @login_required
209
+ def analyze_character():
210
+ """์บ๋ฆญํ„ฐ ์ •๋ณด๋ฅผ ๋ถ„์„ํ•˜์—ฌ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ"""
211
+ data = request.get_json()
212
+ char_name = data.get('name')
213
+ char_desc = data.get('description')
214
+ project_context = data.get('context', '') # ์ž‘ํ’ˆ ๋ฐฐ๊ฒฝ ์ •๋ณด
215
+ model_name = data.get('model_name')
216
+
217
+ if not char_desc or not model_name:
218
+ return jsonify({"error": "๋ถ„์„ํ•  ์ •๋ณด์™€ ๋ชจ๋ธ๋ช…์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."}), 400
219
+
220
+ prompt = f"""
221
+ ๋‹น์‹ ์€ ์›นํˆฐ ์บ๋ฆญํ„ฐ ์‹œํŠธ ์ œ์ž‘์„ ์œ„ํ•œ ์ „๋ฌธ๊ฐ€์ž…๋‹ˆ๋‹ค. ์•„๋ž˜ ์ œ๊ณต๋œ [์ž‘ํ’ˆ ๋ฐฐ๊ฒฝ ์ •๋ณด]์™€ [์บ๋ฆญํ„ฐ ์ •๋ณด]๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ, ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์ธ๊ณต์ง€๋Šฅ์ด ์ดํ•ดํ•˜๊ธฐ ์‰ฌ์šด ์ƒ์„ธํ•œ ์˜์–ด ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ž‘์„ฑํ•ด์ฃผ์„ธ์š”.
222
+
223
+ [์ž‘ํ’ˆ ๋ฐฐ๊ฒฝ ์ •๋ณด]
224
+ {project_context}
225
+
226
+ [์บ๋ฆญํ„ฐ ์ •๋ณด]
227
+ ์ด๋ฆ„: {char_name}
228
+ ์„ค๋ช…: {char_desc}
229
+
230
+ [์ž‘์„ฑ ๊ทœ์น™]
231
+ 1. ๋ฐ˜๋“œ์‹œ ์•„๋ž˜ 5๊ฐ€์ง€ ์„น์…˜ ๊ตฌ์กฐ๋ฅผ ์œ ์ง€ํ•˜์„ธ์š”.
232
+ 2. ๊ฐ ์„น์…˜์˜ [ ] ๋ถ€๋ถ„์€ ์บ๋ฆญํ„ฐ ์ •๋ณด์™€ ์ž‘ํ’ˆ์˜ ๋ถ„์œ„๊ธฐ์— ๋งž์ถฐ ๊ตฌ์ฒด์ ์ธ ์˜์–ด ๋‹จ์–ด๋กœ ์ฑ„์šฐ์„ธ์š”.
233
+ 3. ์ •๋ณด๊ฐ€ ๋ถ€์กฑํ•œ ๋ถ€๋ถ„์€ ์ž‘ํ’ˆ ๋ฐฐ๊ฒฝ์„ ๊ณ ๋ คํ•˜์—ฌ ์บ๋ฆญํ„ฐ์˜ ์„ฑ๊ฒฉ๊ณผ ์™ธ๋ชจ์— ์–ด์šธ๋ฆฌ๋Š” ์ ์ ˆํ•œ ์„ค์ •์„ ์ถ”์ธกํ•˜์—ฌ ๋ณด์™„ํ•˜์„ธ์š”.
234
+ 4. ๊ฒฐ๊ณผ๋ฌผ์€ ๋ฐ˜๋“œ์‹œ ์•„๋ž˜ ํ˜•์‹์˜ ํ…์ŠคํŠธ๋งŒ ์ถœ๋ ฅํ•˜์„ธ์š”. ๋‹ค๋ฅธ ์„ค๋ช…์€ ์ƒ๋žตํ•ฉ๋‹ˆ๋‹ค.
235
+
236
+ ### [1. Style & Quality]
237
+ (Masterpiece), (High-quality Korean webtoon art style), (Clean line art), (Vibrant digital coloring), (8k resolution), (Detailed character design).
238
+
239
+ ### [2. Character Core]
240
+ A [Age: ๋‚˜์ด] [Gender: ์„ฑ๋ณ„] with [Personality/Vibe: ๋ถ„์œ„๊ธฐ/์„ฑ๊ฒฉ].
241
+ [Face: ์ด๋ชฉ๊ตฌ๋น„ ํŠน์ง•], [Eyes: ๋ˆˆ๋งค์™€ ๋ˆˆ๋™์ž ์ƒ‰], [Hair: ํ—ค์–ด์Šคํƒ€์ผ๊ณผ ๋จธ๋ฆฌ์ƒ‰].
242
+
243
+ ### [3. Outfit & Accessories]
244
+ Wearing [Outfit: ์˜์ƒ ์ƒ์„ธ], [Accessories: ์•ก์„ธ์„œ๋ฆฌ ๋ฐ ์†Œํ’ˆ].
245
+ [Theme: ์žฅ๋ฅด์  ํŠน์ง• - ์˜ˆ: imperial military uniform, modern streetwear].
246
+
247
+ ### [4. Pose & Composition]
248
+ [Shot Type: ๊ตฌ๋„ - ์˜ˆ: Full body portrait], [Pose: ์ž์„ธ - ์˜ˆ: Standing facing front], (Neutral pose), (Simple background), (Character sheet format).
249
+
250
+ ### [5. Atmosphere & Lighting]
251
+ [Mood: ๋ถ„์œ„๊ธฐ - ์˜ˆ: Calm and mysterious], [Lighting: ์กฐ๋ช… - ์˜ˆ: Soft rim lighting], (Atmospheric depth).
252
+ """
253
+
254
+ try:
255
+ from app.gemini_client import GeminiClient
256
+
257
+ if model_name.startswith('gemini:'):
258
+ actual_model = model_name.split(':', 1)[1]
259
+ client = GeminiClient()
260
+ response = client.generate_response(prompt, model_name=actual_model)
261
+ analysis_result = response.get('response') if isinstance(response, dict) else (response.text if hasattr(response, 'text') else str(response))
262
+ else:
263
+ # Ollama API ํ˜ธ์ถœ
264
+ ollama_response = requests.post(
265
+ f'{Config.OLLAMA_BASE_URL}/api/generate',
266
+ json={
267
+ 'model': model_name,
268
+ 'prompt': prompt,
269
+ 'stream': False
270
+ },
271
+ timeout=60
272
+ )
273
+ if ollama_response.status_code == 200:
274
+ analysis_result = ollama_response.json().get('response', '')
275
+ else:
276
+ return jsonify({"success": False, "error": f"Ollama API ์˜ค๋ฅ˜: {ollama_response.status_code}"})
277
+
278
+ return jsonify({"success": True, "analysis": analysis_result})
279
+ except Exception as e:
280
+ current_app.logger.error(f"Character analysis error: {str(e)}")
281
+ return jsonify({"success": False, "error": str(e)})
282
+
283
  @creation_bp.route('/webnovel', methods=['GET'])
284
  @login_required
285
  def webnovel():
 
339
  @creation_bp.route('/api/generate-image', methods=['POST'])
340
  @login_required
341
  def api_generate_image():
342
+ """ํ…์ŠคํŠธ ํ”„๋กฌํ”„ํŠธ์™€ ์ฐธ์กฐ ์ด๋ฏธ์ง€(์ฝ˜ํ‹ฐ, ์บ๋ฆญํ„ฐ ๋“ฑ)๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ์ด๋ฏธ์ง€ ์ƒ์„ฑ"""
343
  data = request.json
344
  prompt = data.get('prompt')
345
  model_name = data.get('model_name')
346
+ image_url = data.get('image_url') # ๋‹จ์ผ ์ด๋ฏธ์ง€ URL (ํ•˜์œ„ ํ˜ธํ™˜์„ฑ)
347
+ image_urls = data.get('image_urls', []) # ๋‹ค์ค‘ ์ด๋ฏธ์ง€ URL ๋ฆฌ์ŠคํŠธ
348
+
349
+ # image_url์ด ์žˆ๊ณ  image_urls๊ฐ€ ๋น„์–ด์žˆ์œผ๋ฉด ํ•ฉ์นจ
350
+ if image_url and not image_urls:
351
+ image_urls = [image_url]
352
 
353
  if not prompt:
354
  return jsonify({'error': 'ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'}), 400
 
360
 
361
  async def generate():
362
  # gemini_client๋ฅผ ํ†ตํ•ด ์ง์ ‘ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์š”์ฒญ
 
363
  target_model = model_name.replace('gemini:', '')
364
 
365
+ # ์‹œ์Šคํ…œ ์ง€์นจ ๊ตฌ์„ฑ
366
+ system_instruction = (
367
+ "You are a professional webtoon artist specialized in clean line art. "
368
+ "Please generate a high-quality Korean webtoon style line art based on the provided references. "
369
+ "If multiple images are provided, the first image is usually a sketch/storyboard (conty), "
370
+ "and the subsequent images are character or style references. "
371
+ "Maintain the composition of the sketch while applying the detailed designs and features from the reference images. "
372
+ "Output ONLY the generated image."
373
+ )
374
 
375
  contents = [system_instruction]
376
 
377
+ # ๋ชจ๋“  ์ฐธ์กฐ ์ด๋ฏธ์ง€ ๋กœ๋“œ ๋ฐ ์ถ”๊ฐ€
378
+ if image_urls:
379
+ for url in image_urls:
380
+ try:
381
+ # /uploads/ ์ ‘๋‘์–ด ์ œ๊ฑฐํ•˜์—ฌ ์ƒ๋Œ€ ๊ฒฝ๋กœ ํš๋“
382
+ rel_path = url.replace('/uploads/', '')
383
+ img_obj = await service.load_image(rel_path, max_size=1024)
384
+ if img_obj:
385
+ contents.append(img_obj)
386
+ current_app.logger.info(f"Reference image attached: {rel_path}")
387
+ except Exception as e:
388
+ current_app.logger.error(f"Failed to load image {url}: {e}")
389
 
390
  contents.append(f"Prompt: {prompt}")
391
 
app/routers/webtoon_conti.py CHANGED
@@ -10,6 +10,30 @@ import logging
10
  logger = logging.getLogger(__name__)
11
  webtoon_conti_bp = Blueprint('webtoon_conti', __name__, url_prefix='/webtoon-conti')
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  # ์ง€์—ฐ ๋กœ๋”ฉ์„ ์œ„ํ•ด ์ „์—ญ ๋ณ€์ˆ˜๋Š” ์œ ์ง€ํ•˜๋˜ ์ดˆ๊ธฐํ™”๋Š” ํ•จ์ˆ˜ ๋‚ด๋ถ€์—์„œ ์ง„ํ–‰
14
  _conti_service = None
15
  _visual_service = None
@@ -106,7 +130,9 @@ def create_project():
106
  file_id=file_id,
107
  episode_id=episode_id,
108
  title=title,
109
- description=description
 
 
110
  )
111
  db.session.add(project)
112
  db.session.commit()
@@ -129,9 +155,13 @@ def extract_visuals():
129
  data = request.get_json()
130
  text = data.get('text')
131
  file_id = data.get('file_id')
 
 
132
  project_id = data.get('project_id')
133
  analysis_model = data.get('analysis_model', 'gemini-1.5-flash')
134
  gen_model = data.get('gen_model')
 
 
135
 
136
  if not text:
137
  return jsonify({"error": "๋ถ„์„ํ•  ํ…์ŠคํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."}), 400
@@ -140,12 +170,62 @@ def extract_visuals():
140
  return jsonify({"error": "ํ”„๋กœ์ ํŠธ ID๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."}), 400
141
 
142
  async def _process():
143
- # 1. ํ…์ŠคํŠธ๋ฅผ ์ปท๋ณ„๋กœ ๋ถ„ํ•  (๋ถ„์„ ์ „์šฉ ๋ชจ๋ธ ์‚ฌ์šฉ)
 
 
 
 
 
 
 
 
 
 
144
  service = get_conti_service()
145
- panels_data = await service.divide_into_panels(text, int(file_id) if file_id else 0, model=analysis_model)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  created_panels = []
148
- # 2. ๊ฐ ๋ถ„ํ• ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ContiPanel๋กœ ์ €์žฅ ๋ฐ ์ด๋ฏธ์ง€ ์ง์ ‘ ์ƒ์„ฑ ์‹œ๋„ (์ƒ์„ฑ ์ „์šฉ ๋ชจ๋ธ ์‚ฌ์šฉ)
149
  image_tasks = []
150
 
151
  for data in panels_data:
@@ -161,7 +241,11 @@ def extract_visuals():
161
 
162
  # ์ƒ์„ฑ ๋ชจ๋ธ์ด ์ง€์ •๋˜์–ด ์žˆ๊ณ  Gemini/Imagen ๊ณ„์—ด์ธ ๊ฒฝ์šฐ ์ง์ ‘ ์ œ์ž‘ ์š”์ฒญ
163
  if gen_model and ('imagen' in gen_model.lower() or 'gemini' in gen_model.lower()):
164
- direct_prompt = f"{panel.description}. Composition: {panel.composition}. Webtoon style."
 
 
 
 
165
  image_tasks.append((panel, service.generate_image_directly(direct_prompt, gen_model)))
166
 
167
  created_panels.append(panel)
@@ -202,6 +286,7 @@ def generate_samples():
202
  panel_id = data.get('panel_id')
203
  sample_count = data.get('sample_count', 3)
204
  model = data.get('model') # ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ๋ชจ๋ธ์„ ๋ณด๋‚ผ ๊ฒฝ์šฐ ๋Œ€๋น„
 
205
 
206
  panel = ContiPanel.query.get_or_404(panel_id)
207
  project = panel.conti
@@ -216,14 +301,19 @@ def generate_samples():
216
  elif project.file: # ํ”„๋กœ์ ํŠธ์— ์—ฐ๊ฒฐ๋œ ํŒŒ์ผ์˜ ๋ชจ๋ธ์ด ์žˆ์œผ๋ฉด ์‚ฌ์šฉ
217
  visual_service.update_model(project.file.model_name or 'gemini-1.5-flash')
218
 
219
- # 2. ํŒจ๋„ ๋ฌ˜์‚ฌ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์ƒ์„ธ ์‹œ๊ฐ ์š”์†Œ ์ถ”์ถœ
220
- visual_info = await visual_service.extract_visuals(panel.description, project.file_id)
 
221
 
222
  # ์„ ํƒ๋œ ๋ชจ๋ธ์ด Gemini/Imagen์ด๋ฉด ์ง์ ‘ ์ƒ์„ฑ
223
  if model and ('imagen' in model.lower() or 'gemini' in model.lower()):
224
  logger.info(f"Using direct AI generation for samples using {model}")
225
  prompt_text = visual_service.generate_combined_prompt(visual_info)
226
 
 
 
 
 
227
  # ๋ณ‘๋ ฌ ์ด๋ฏธ์ง€ ์ƒ์„ฑ
228
  coros = [service.generate_image_directly(prompt_text, model) for _ in range(sample_count)]
229
  image_results = await asyncio.gather(*coros)
@@ -249,7 +339,8 @@ def generate_samples():
249
  result = await service.generate_multi_samples(
250
  visual_info,
251
  project.file_id,
252
- sample_count=sample_count
 
253
  )
254
  return result
255
 
@@ -333,6 +424,21 @@ def select_final():
333
  "image_path": panel.image_path
334
  })
335
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  @webtoon_conti_bp.route('/api/panels', methods=['POST'])
337
  @login_required
338
  def create_panel():
 
10
  logger = logging.getLogger(__name__)
11
  webtoon_conti_bp = Blueprint('webtoon_conti', __name__, url_prefix='/webtoon-conti')
12
 
13
+ @webtoon_conti_bp.route('/api/global-prompts', methods=['GET'])
14
+ @login_required
15
+ def get_global_prompts():
16
+ """์ „์—ญ(๊ธฐ๋ณธ) ํ”„๋กฌํ”„ํŠธ ์„ค์ • ์กฐํšŒ"""
17
+ from app.database import SystemConfig
18
+ return jsonify({
19
+ "analysis_prompt": SystemConfig.get_config('webtoon_conti_default_analysis_prompt', ''),
20
+ "gen_prompt": SystemConfig.get_config('webtoon_conti_default_gen_prompt', '')
21
+ })
22
+
23
+ @webtoon_conti_bp.route('/api/global-prompts', methods=['POST'])
24
+ @login_required
25
+ def save_global_prompts():
26
+ """์ „์—ญ(๊ธฐ๋ณธ) ํ”„๋กฌํ”„ํŠธ ์„ค์ • ์ €์žฅ"""
27
+ data = request.get_json()
28
+ from app.database import SystemConfig
29
+
30
+ if 'analysis_prompt' in data:
31
+ SystemConfig.set_config('webtoon_conti_default_analysis_prompt', data.get('analysis_prompt'))
32
+ if 'gen_prompt' in data:
33
+ SystemConfig.set_config('webtoon_conti_default_gen_prompt', data.get('gen_prompt'))
34
+
35
+ return jsonify({"success": True, "message": "๊ธฐ๋ณธ ํ”„๋กฌํ”„ํŠธ๋กœ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."})
36
+
37
  # ์ง€์—ฐ ๋กœ๋”ฉ์„ ์œ„ํ•ด ์ „์—ญ ๋ณ€์ˆ˜๋Š” ์œ ์ง€ํ•˜๋˜ ์ดˆ๊ธฐํ™”๋Š” ํ•จ์ˆ˜ ๋‚ด๋ถ€์—์„œ ์ง„ํ–‰
38
  _conti_service = None
39
  _visual_service = None
 
130
  file_id=file_id,
131
  episode_id=episode_id,
132
  title=title,
133
+ description=description,
134
+ analysis_prompt=data.get('analysis_prompt'),
135
+ gen_prompt=data.get('gen_prompt')
136
  )
137
  db.session.add(project)
138
  db.session.commit()
 
155
  data = request.get_json()
156
  text = data.get('text')
157
  file_id = data.get('file_id')
158
+ episode_ids = data.get('episode_ids', []) # ๋‹ค์ค‘ ํšŒ์ฐจ ID
159
+ graph_episode_titles = data.get('graph_episode_titles', []) # GraphRAG ํšŒ์ฐจ ์ œ๋ชฉ๋“ค
160
  project_id = data.get('project_id')
161
  analysis_model = data.get('analysis_model', 'gemini-1.5-flash')
162
  gen_model = data.get('gen_model')
163
+ analysis_prompt = data.get('analysis_prompt') # ์ถ”๊ฐ€ ๋ถ„์„ ์ง€์นจ
164
+ gen_prompt = data.get('gen_prompt') # ์ถ”๊ฐ€ ์ƒ์„ฑ ์ง€์นจ
165
 
166
  if not text:
167
  return jsonify({"error": "๋ถ„์„ํ•  ํ…์ŠคํŠธ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค."}), 400
 
170
  return jsonify({"error": "ํ”„๋กœ์ ํŠธ ID๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."}), 400
171
 
172
  async def _process():
173
+ # 0. ํ”„๋กœ์ ํŠธ ํ”„๋กฌํ”„ํŠธ ์ •๋ณด ์—…๋ฐ์ดํŠธ (์ €์žฅ ๊ด€๋ฆฌ์šฉ)
174
+ project = WebtoonConti.query.get(project_id)
175
+ if project:
176
+ if analysis_prompt is not None:
177
+ project.analysis_prompt = analysis_prompt
178
+ if gen_prompt is not None:
179
+ project.gen_prompt = gen_prompt
180
+ db.session.commit()
181
+ logger.info(f"Updated prompts for project {project_id}")
182
+
183
+ # 1. ์ถ”๊ฐ€ ์ปจํ…์ŠคํŠธ ์ˆ˜์ง‘ (ParentChunk + ์„ ํƒ๋œ EpisodeAnalysis + GraphRAG)
184
  service = get_conti_service()
185
+ context_parts = []
186
+
187
+ if file_id and file_id != 0:
188
+ # ParentChunk (์„ธ๊ณ„๊ด€, ์บ๋ฆญํ„ฐ ์„ค์ • ๋“ฑ) - ๋ฌด์กฐ๊ฑด ์ฐธ์กฐ
189
+ from app.database import ParentChunk
190
+ parent_chunk = ParentChunk.query.filter_by(file_id=file_id).first()
191
+ if parent_chunk:
192
+ context_parts.append(f"### ์„ธ๊ณ„๊ด€ ๋ฐ ์บ๋ฆญํ„ฐ ๊ธฐ๋ณธ ์„ค์ • (Parent Chunk)\n{parent_chunk.world_view or ''}\n{parent_chunk.characters or ''}")
193
+
194
+ # ์„ ํƒ๋œ ํšŒ์ฐจ๋“ค์˜ ๋ถ„์„ ๋‚ด์šฉ (EpisodeAnalysis)
195
+ if episode_ids:
196
+ from app.database import EpisodeAnalysis
197
+ episodes = EpisodeAnalysis.query.filter(EpisodeAnalysis.id.in_(episode_ids)).all()
198
+ for ep in episodes:
199
+ context_parts.append(f"### ํšŒ์ฐจ ์š”์•ฝ: {ep.episode_title}\n{ep.analysis_content}")
200
+
201
+ # ์„ ํƒ๋œ ํšŒ์ฐจ๋“ค์˜ GraphRAG ์ •๋ณด
202
+ if graph_episode_titles:
203
+ from app.database import GraphEntity, GraphEvent, GraphRelationship
204
+ for title in graph_episode_titles:
205
+ entities = GraphEntity.query.filter_by(file_id=file_id, episode_title=title).all()
206
+ events = GraphEvent.query.filter_by(file_id=file_id, episode_title=title).all()
207
+
208
+ if entities or events:
209
+ graph_info = f"### GraphRAG ์ฐธ์กฐ ({title})\n"
210
+ if entities:
211
+ graph_info += "์ธ๋ฌผ/์žฅ์†Œ: " + ", ".join([f"{e.entity_name}({e.entity_type})" for e in entities]) + "\n"
212
+ if events:
213
+ graph_info += "์ฃผ์š” ์‚ฌ๊ฑด: " + ", ".join([e.event_name for e in events]) + "\n"
214
+ context_parts.append(graph_info)
215
+
216
+ context_text = "\n\n".join(context_parts)
217
+
218
+ # 2. ํ…์ŠคํŠธ๋ฅผ ์ปท๋ณ„๋กœ ๋ถ„ํ•  (๋ถ„์„ ์ „์šฉ ๋ชจ๋ธ ์‚ฌ์šฉ)
219
+ panels_data = await service.divide_into_panels(
220
+ text,
221
+ int(file_id) if file_id else 0,
222
+ model=analysis_model,
223
+ context_text=context_text,
224
+ custom_prompt=analysis_prompt
225
+ )
226
 
227
  created_panels = []
228
+ # 3. ๊ฐ ๋ถ„ํ• ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ContiPanel๋กœ ์ €์žฅ ๋ฐ ์ด๋ฏธ์ง€ ์ง์ ‘ ์ƒ์„ฑ ์‹œ๋„ (์ƒ์„ฑ ์ „์šฉ ๋ชจ๋ธ ์‚ฌ์šฉ)
229
  image_tasks = []
230
 
231
  for data in panels_data:
 
241
 
242
  # ์ƒ์„ฑ ๋ชจ๋ธ์ด ์ง€์ •๋˜์–ด ์žˆ๊ณ  Gemini/Imagen ๊ณ„์—ด์ธ ๊ฒฝ์šฐ ์ง์ ‘ ์ œ์ž‘ ์š”์ฒญ
243
  if gen_model and ('imagen' in gen_model.lower() or 'gemini' in gen_model.lower()):
244
+ # ์ปค์Šคํ…€ ์ƒ์„ฑ ํ”„๋กฌํ”„ํŠธ๊ฐ€ ์žˆ์œผ๋ฉด ๋ฐ˜์˜
245
+ direct_prompt = f"Scene: {panel.description}. Dialogue/Action: {panel.dialogue or ''}. Composition: {panel.composition}. Webtoon style."
246
+ if gen_prompt:
247
+ direct_prompt = f"{direct_prompt} Style Guidelines: {gen_prompt}"
248
+
249
  image_tasks.append((panel, service.generate_image_directly(direct_prompt, gen_model)))
250
 
251
  created_panels.append(panel)
 
286
  panel_id = data.get('panel_id')
287
  sample_count = data.get('sample_count', 3)
288
  model = data.get('model') # ํ”„๋ก ํŠธ์—”๋“œ์—์„œ ๋ชจ๋ธ์„ ๋ณด๋‚ผ ๊ฒฝ์šฐ ๋Œ€๋น„
289
+ gen_prompt = data.get('gen_prompt') # ์ถ”๊ฐ€ ์Šคํƒ€์ผ ์ง€์นจ
290
 
291
  panel = ContiPanel.query.get_or_404(panel_id)
292
  project = panel.conti
 
301
  elif project.file: # ํ”„๋กœ์ ํŠธ์— ์—ฐ๊ฒฐ๋œ ํŒŒ์ผ์˜ ๋ชจ๋ธ์ด ์žˆ์œผ๋ฉด ์‚ฌ์šฉ
302
  visual_service.update_model(project.file.model_name or 'gemini-1.5-flash')
303
 
304
+ # 2. ํŒจ๋„ ๋ฌ˜์‚ฌ์™€ ๋Œ€์‚ฌ/์ง€๋ฌธ์„ ํ•จ๊ป˜ ๋ฐ”ํƒ•์œผ๋กœ ์ƒ์„ธ ์‹œ๊ฐ ์š”์†Œ ์ถ”์ถœ
305
+ combined_text = f"Visual Description: {panel.description}\nDialogue/Narration: {panel.dialogue or ''}"
306
+ visual_info = await visual_service.extract_visuals(combined_text, project.file_id)
307
 
308
  # ์„ ํƒ๋œ ๋ชจ๋ธ์ด Gemini/Imagen์ด๋ฉด ์ง์ ‘ ์ƒ์„ฑ
309
  if model and ('imagen' in model.lower() or 'gemini' in model.lower()):
310
  logger.info(f"Using direct AI generation for samples using {model}")
311
  prompt_text = visual_service.generate_combined_prompt(visual_info)
312
 
313
+ # ์ปค์Šคํ…€ ์ƒ์„ฑ ํ”„๋กฌํ”„ํŠธ๊ฐ€ ์žˆ์œผ๋ฉด ๋ฐ˜์˜
314
+ if gen_prompt:
315
+ prompt_text = f"{prompt_text} Style Guidelines: {gen_prompt}"
316
+
317
  # ๋ณ‘๋ ฌ ์ด๋ฏธ์ง€ ์ƒ์„ฑ
318
  coros = [service.generate_image_directly(prompt_text, model) for _ in range(sample_count)]
319
  image_results = await asyncio.gather(*coros)
 
339
  result = await service.generate_multi_samples(
340
  visual_info,
341
  project.file_id,
342
+ sample_count=sample_count,
343
+ custom_prompt=gen_prompt # ์ปค์Šคํ…€ ํ”„๋กฌํ”„ํŠธ ์ถ”๊ฐ€
344
  )
345
  return result
346
 
 
424
  "image_path": panel.image_path
425
  })
426
 
427
+ @webtoon_conti_bp.route('/api/projects/<int:project_id>/prompts', methods=['PUT'])
428
+ @login_required
429
+ def update_project_prompts(project_id):
430
+ """ํ”„๋กœ์ ํŠธ์˜ ํ”„๋กฌํ”„ํŠธ ์„ค์ • ์—…๋ฐ์ดํŠธ"""
431
+ data = request.get_json()
432
+ project = WebtoonConti.query.get_or_404(project_id)
433
+
434
+ if 'analysis_prompt' in data:
435
+ project.analysis_prompt = data.get('analysis_prompt')
436
+ if 'gen_prompt' in data:
437
+ project.gen_prompt = data.get('gen_prompt')
438
+
439
+ db.session.commit()
440
+ return jsonify({"success": True, "message": "ํ”„๋กฌํ”„ํŠธ๊ฐ€ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค."})
441
+
442
  @webtoon_conti_bp.route('/api/panels', methods=['POST'])
443
  @login_required
444
  def create_panel():
app/routes.py CHANGED
@@ -54,6 +54,8 @@ def get_default_admin_menu():
54
  "roles": ["admin", "webtoon_pm", "webtoon_admin", "producer", "user"],
55
  "items": [
56
  {"label": "์›น์†Œ์„ค", "endpoint": "creation.webnovel"},
 
 
57
  {"label": "์›นํˆฐ ์ฝ˜ํ‹ฐ", "endpoint": "webtoon_conti.dashboard"},
58
  {"label": "์›นํˆฐ ์„ ํ™”", "endpoint": "main.webtoon_line_art_dashboard"},
59
  {"label": "์ด๋ฏธ์ง€ ์ƒ์„ฑ ํ…Œ์ŠคํŠธ", "endpoint": "creation.image_test"},
@@ -302,6 +304,8 @@ def get_user_menu():
302
  "roles": ["user"],
303
  "items": [
304
  {"label": "์›น์†Œ์„ค", "endpoint": "creation.webnovel"},
 
 
305
  ],
306
  },
307
  {
 
54
  "roles": ["admin", "webtoon_pm", "webtoon_admin", "producer", "user"],
55
  "items": [
56
  {"label": "์›น์†Œ์„ค", "endpoint": "creation.webnovel"},
57
+ {"label": "์บ๋ฆญํ„ฐ ์ œ์ž‘", "endpoint": "creation.character_creation"},
58
+ {"label": "์›นํˆฐ ์„ ํ™” ๋„์ „", "endpoint": "creation.webtoon_line_art_challenge"},
59
  {"label": "์›นํˆฐ ์ฝ˜ํ‹ฐ", "endpoint": "webtoon_conti.dashboard"},
60
  {"label": "์›นํˆฐ ์„ ํ™”", "endpoint": "main.webtoon_line_art_dashboard"},
61
  {"label": "์ด๋ฏธ์ง€ ์ƒ์„ฑ ํ…Œ์ŠคํŠธ", "endpoint": "creation.image_test"},
 
304
  "roles": ["user"],
305
  "items": [
306
  {"label": "์›น์†Œ์„ค", "endpoint": "creation.webnovel"},
307
+ {"label": "์บ๋ฆญํ„ฐ ์ œ์ž‘", "endpoint": "creation.character_creation"},
308
+ {"label": "์›นํˆฐ ์„ ํ™” ๋„์ „", "endpoint": "creation.webtoon_line_art_challenge"},
309
  ],
310
  },
311
  {
app/services/webtoon_conti_service.py CHANGED
@@ -74,7 +74,7 @@ class WebtoonContiService:
74
  logger.error(f"Direct image generation failed: {e}")
75
  return None
76
 
77
- async def divide_into_panels(self, text: str, file_id: int, model: str = 'gemini-1.5-flash'):
78
  """
79
  ์›น์†Œ์„ค ํ…์ŠคํŠธ๋ฅผ ๋ถ„์„ํ•˜์—ฌ ์›นํˆฐ ์ฝ˜ํ‹ฐ ํŒจ๋„(์ปท)๋“ค๋กœ ๋ถ„ํ• ํ•ฉ๋‹ˆ๋‹ค.
80
  """
@@ -89,8 +89,15 @@ class WebtoonContiService:
89
 
90
  # 2. ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ
91
  prompt = get_webtoon_conti_prompt(text, char_info, bg_info)
 
 
 
 
 
 
 
92
 
93
- # 3. LLM ํ˜ธ์ถœ (์„ ํƒ๋œ ๋ชจ๋ธ ์‚ฌ์šฉ)
94
  response_text = ""
95
  if 'gemini' in model.lower():
96
  client = get_gemini_client()
@@ -146,43 +153,51 @@ class WebtoonContiService:
146
  resp = resp.text
147
  else:
148
  resp = str(resp)
149
-
150
- # 1. ๋งˆํฌ๋‹ค์šด ์ฝ”๋“œ ๋ธ”๋ก ์ œ๊ฑฐ
151
  resp = re.sub(r'```json\s*', '', resp)
 
152
  resp = re.sub(r'```\s*', '', resp)
153
 
154
- # 2. JSON ๋‚ด๋ถ€์˜ ์‹ค์ œ ์ค„๋ฐ”๊ฟˆ ๋ฌธ์ž ์ฒ˜๋ฆฌ (๋ฌธ์ž์—ด ๋‚ด์˜ \n์€ ์œ ์ง€ํ•˜๊ณ  ์‹ค์ œ ๊ฐœํ–‰๋งŒ ์ œ๊ฑฐ ์‹œ๋„)
155
- # ์ฃผ์˜: ๋ณต์žกํ•œ ์ •๊ทœ์‹๋ณด๋‹ค๋Š” ๋ฆฌ์ŠคํŠธ/๊ฐ์ฒด ๊ธฐํ˜ธ ๊ธฐ์ค€ ์ถ”์ถœ์ด ์•ˆ์ „ํ•จ
156
 
157
  # 3. ๋ฆฌ์ŠคํŠธ([]) ๋˜๋Š” ๊ฐ์ฒด({})์˜ ์‹œ์ž‘๊ณผ ๋์„ ์ฐพ์•„ ์ถ”์ถœ
 
158
  start_list = resp.find('[')
159
  end_list = resp.rfind(']')
160
 
161
  start_obj = resp.find('{')
162
  end_obj = resp.rfind('}')
163
 
164
- # ๋ฆฌ์ŠคํŠธ๊ฐ€ ์šฐ์„  (ํŒจ๋„ ๋ฆฌ์ŠคํŠธ์ด๋ฏ€๋กœ)
165
- if start_list != -1 and end_list != -1 and end_list > start_list:
166
  return resp[start_list:end_list+1]
167
- elif start_obj != -1 and end_obj != -1 and end_obj > start_obj:
168
  return resp[start_obj:end_obj+1]
169
 
170
- return resp.strip()
171
 
172
  try:
173
  cleaned_response = clean_json_response(response_text)
174
  try:
 
175
  panels_data = json.loads(cleaned_response)
176
  except json.JSONDecodeError as e:
177
- # ํŒŒ์‹ฑ ์‹คํŒจ ์‹œ ๋งˆ์ง€๋ง‰์— ์ฝค๋งˆ๊ฐ€ ์žˆ๊ฑฐ๋‚˜ ์ž˜๋ฆฐ ๊ฒฝ์šฐ ์ฒ˜๋ฆฌ ์‹œ๋„
178
- logger.warning(f"Initial JSON parse failed: {e}. Attempting recovery...")
179
-
180
- # ๋์ด ์ž˜๋ฆฐ ๊ฒฝ์šฐ ๋‹ซ๋Š” ๊ด„ํ˜ธ ์ถ”๊ฐ€ ์‹œ๋„
181
- if '[' in cleaned_response and not cleaned_response.strip().endswith(']'):
 
 
 
 
 
 
182
  try:
 
183
  panels_data = json.loads(cleaned_response.strip() + ']')
184
  except:
185
- # ๋” ๋ณต์žกํ•œ ๋ณต๊ตฌ๋Š” ์ƒ๋žตํ•˜๊ณ  ์›๋ž˜ ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œํ‚ด
186
  raise e
187
  else:
188
  raise e
@@ -274,7 +289,8 @@ class WebtoonContiService:
274
  visual_info: VisualExtractionOutput,
275
  file_id: int,
276
  workflow_name: str = "default_webtoon_conti",
277
- sample_count: int = 3
 
278
  ):
279
  """
280
  ๋™์ผํ•œ ์‹œ๊ฐ ๋ฌ˜์‚ฌ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์„œ๋กœ ๋‹ค๋ฅธ ์‹œ๋“œ๊ฐ’์„ ์‚ฌ์šฉํ•˜์—ฌ ์—ฌ๋Ÿฌ ์ƒ˜ํ”Œ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
@@ -284,6 +300,7 @@ class WebtoonContiService:
284
  file_id: ์บ๋ฆญํ„ฐ ๊ฒ€์ƒ‰์šฉ ํŒŒ์ผ ID
285
  workflow_name: ์‚ฌ์šฉํ•  ComfyUI ์›Œํฌํ”Œ๋กœ์šฐ ์ด๋ฆ„
286
  sample_count: ์ƒ์„ฑํ•  ์ƒ˜ํ”Œ ์ˆ˜ (๊ธฐ๋ณธ 3๊ฐœ)
 
287
  """
288
  # 1. ์บ๋ฆญํ„ฐ ์ฐธ์กฐ ์ด๋ฏธ์ง€ ์กฐํšŒ
289
  character_image_path = None
@@ -305,6 +322,10 @@ class WebtoonContiService:
305
 
306
  # 3. ์˜์–ด ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ
307
  prompt_text = self.visual_service.generate_combined_prompt(visual_info)
 
 
 
 
308
 
309
  samples = []
310
  # 4. ์—ฌ๋Ÿฌ ์‹œ๋“œ๋กœ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์š”์ฒญ
 
74
  logger.error(f"Direct image generation failed: {e}")
75
  return None
76
 
77
+ async def divide_into_panels(self, text: str, file_id: int, model: str = 'gemini-1.5-flash', context_text: str = "", custom_prompt: str = ""):
78
  """
79
  ์›น์†Œ์„ค ํ…์ŠคํŠธ๋ฅผ ๋ถ„์„ํ•˜์—ฌ ์›นํˆฐ ์ฝ˜ํ‹ฐ ํŒจ๋„(์ปท)๋“ค๋กœ ๋ถ„ํ• ํ•ฉ๋‹ˆ๋‹ค.
80
  """
 
89
 
90
  # 2. ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ
91
  prompt = get_webtoon_conti_prompt(text, char_info, bg_info)
92
+
93
+ # 3. ์ถ”๊ฐ€ ์ปจํ…์ŠคํŠธ ๋ฐ ์ปค์Šคํ…€ ํ”„๋กฌํ”„ํŠธ ๋ฐ˜์˜
94
+ if context_text:
95
+ prompt = f"### ์ถ”๊ฐ€ ์ฐธ์กฐ ์ •๋ณด\n{context_text}\n\n{prompt}"
96
+
97
+ if custom_prompt:
98
+ prompt = f"{prompt}\n\n### ์‚ฌ์šฉ์ž ์ถ”๊ฐ€ ์ง€์นจ\n{custom_prompt}"
99
 
100
+ # 4. LLM ํ˜ธ์ถœ (์„ ํƒ๋œ ๋ชจ๋ธ ์‚ฌ์šฉ)
101
  response_text = ""
102
  if 'gemini' in model.lower():
103
  client = get_gemini_client()
 
153
  resp = resp.text
154
  else:
155
  resp = str(resp)
156
+
157
+ # 1. ๋งˆํฌ๋‹ค์šด ์ฝ”๋“œ ๋ธ”๋ก ์ œ๊ฑฐ (๋‹ค์–‘ํ•œ ํ˜•์‹ ๋Œ€์‘)
158
  resp = re.sub(r'```json\s*', '', resp)
159
+ resp = re.sub(r'```JSON\s*', '', resp)
160
  resp = re.sub(r'```\s*', '', resp)
161
 
162
+ # 2. ์•ž๋’ค ๊ณต๋ฐฑ ์ œ๊ฑฐ
163
+ resp = resp.strip()
164
 
165
  # 3. ๋ฆฌ์ŠคํŠธ([]) ๋˜๋Š” ๊ฐ์ฒด({})์˜ ์‹œ์ž‘๊ณผ ๋์„ ์ฐพ์•„ ์ถ”์ถœ
166
+ # ์—ฌ๋Ÿฌ ๊ฐœ์˜ JSON ๋ธ”๋ก์ด ์žˆ์„ ๊ฒฝ์šฐ ์ฒซ ๋ฒˆ์งธ ๋ธ”๋ก์„ ์šฐ์„ ์ ์œผ๋กœ ์‹œ๋„
167
  start_list = resp.find('[')
168
  end_list = resp.rfind(']')
169
 
170
  start_obj = resp.find('{')
171
  end_obj = resp.rfind('}')
172
 
173
+ if start_list != -1 and end_list != -1 and (start_obj == -1 or start_list < start_obj):
 
174
  return resp[start_list:end_list+1]
175
+ elif start_obj != -1 and end_obj != -1:
176
  return resp[start_obj:end_obj+1]
177
 
178
+ return resp
179
 
180
  try:
181
  cleaned_response = clean_json_response(response_text)
182
  try:
183
+ # 1์ฐจ ์‹œ๋„: ์ „์ฒด ํŒŒ์‹ฑ
184
  panels_data = json.loads(cleaned_response)
185
  except json.JSONDecodeError as e:
186
+ # 2์ฐจ ์‹œ๋„: Extra data ์—๋Ÿฌ์ธ ๊ฒฝ์šฐ (์œ ํšจํ•œ JSON ๋’ค์— ๊ฐ€๋น„์ง€๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ)
187
+ if "Extra data" in str(e):
188
+ try:
189
+ logger.warning(f"JSON has extra data, attempting raw_decode. Error: {e}")
190
+ decoder = json.JSONDecoder()
191
+ panels_data, index = decoder.raw_decode(cleaned_response)
192
+ except Exception as e2:
193
+ logger.error(f"raw_decode failed: {e2}")
194
+ raise e
195
+ # 3์ฐจ ์‹œ๋„: ์ž˜๋ฆฐ JSON ๋ณต๊ตฌ ์‹œ๋„
196
+ elif '[' in cleaned_response and not cleaned_response.strip().endswith(']'):
197
  try:
198
+ logger.warning(f"Incomplete JSON list detected, attempting recovery. Error: {e}")
199
  panels_data = json.loads(cleaned_response.strip() + ']')
200
  except:
 
201
  raise e
202
  else:
203
  raise e
 
289
  visual_info: VisualExtractionOutput,
290
  file_id: int,
291
  workflow_name: str = "default_webtoon_conti",
292
+ sample_count: int = 3,
293
+ custom_prompt: str = ""
294
  ):
295
  """
296
  ๋™์ผํ•œ ์‹œ๊ฐ ๋ฌ˜์‚ฌ๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ ์„œ๋กœ ๋‹ค๋ฅธ ์‹œ๋“œ๊ฐ’์„ ์‚ฌ์šฉํ•˜์—ฌ ์—ฌ๋Ÿฌ ์ƒ˜ํ”Œ ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.
 
300
  file_id: ์บ๋ฆญํ„ฐ ๊ฒ€์ƒ‰์šฉ ํŒŒ์ผ ID
301
  workflow_name: ์‚ฌ์šฉํ•  ComfyUI ์›Œํฌํ”Œ๋กœ์šฐ ์ด๋ฆ„
302
  sample_count: ์ƒ์„ฑํ•  ์ƒ˜ํ”Œ ์ˆ˜ (๊ธฐ๋ณธ 3๊ฐœ)
303
+ custom_prompt: ์‚ฌ์šฉ์ž ์ถ”๊ฐ€ ์Šคํƒ€์ผ ์ง€์นจ
304
  """
305
  # 1. ์บ๋ฆญํ„ฐ ์ฐธ์กฐ ์ด๋ฏธ์ง€ ์กฐํšŒ
306
  character_image_path = None
 
322
 
323
  # 3. ์˜์–ด ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ
324
  prompt_text = self.visual_service.generate_combined_prompt(visual_info)
325
+
326
+ # ์ปค์Šคํ…€ ํ”„๋กฌํ”„ํŠธ(๊ทธ๋ฆผ ์ฝ˜ํ‹ฐ์šฉ ํ”„๋กฌํ”„ํŠธ) ๋ฐ˜์˜
327
+ if custom_prompt:
328
+ prompt_text = f"{prompt_text}, {custom_prompt}"
329
 
330
  samples = []
331
  # 4. ์—ฌ๋Ÿฌ ์‹œ๋“œ๋กœ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์š”์ฒญ
force_update_menu.py CHANGED
@@ -71,5 +71,6 @@ if __name__ == "__main__":
71
 
72
 
73
 
 
74
 
75
 
 
71
 
72
 
73
 
74
+
75
 
76
 
templates/admin_webtoon_milestone_producer_manager.html CHANGED
@@ -365,5 +365,6 @@
365
 
366
 
367
 
 
368
 
369
 
 
365
 
366
 
367
 
368
+
369
 
370
 
templates/character_creation.html ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "creation_base.html" %}
2
+
3
+ {% block title %}์บ๋ฆญํ„ฐ ์ œ์ž‘ - SOYMEDIA{% endblock %}
4
+
5
+ {% block nav %}
6
+ {% set admin_nav_title = 'AI ์บ๋ฆญํ„ฐ ์ œ์ž‘' %}
7
+ {% set admin_nav_icon = '๐Ÿ‘ค' %}
8
+ {% include '_admin_nav.html' %}
9
+ {% endblock %}
10
+
11
+ {% block extra_css %}
12
+ <style>
13
+ .character-card {
14
+ border: 1px solid var(--border);
15
+ border-radius: 12px;
16
+ background: white;
17
+ margin-bottom: 1.5rem;
18
+ overflow: hidden;
19
+ transition: all 0.3s;
20
+ }
21
+ .character-card:hover {
22
+ box-shadow: 0 4px 15px rgba(0,0,0,0.1);
23
+ transform: translateY(-2px);
24
+ }
25
+ .character-header {
26
+ padding: 1rem 1.5rem;
27
+ background: #f8f9fa;
28
+ border-bottom: 1px solid var(--border);
29
+ display: flex;
30
+ justify-content: space-between;
31
+ align-items: center;
32
+ }
33
+ .character-body {
34
+ padding: 1.5rem;
35
+ }
36
+ .gen-input-column {
37
+ display: flex;
38
+ flex-direction: column;
39
+ }
40
+ #genPrompt {
41
+ flex-grow: 1;
42
+ min-height: 400px;
43
+ }
44
+ .result-image-container {
45
+ width: 100%;
46
+ max-width: 800px;
47
+ margin: 0 auto;
48
+ border-radius: 12px;
49
+ overflow: hidden;
50
+ background: #f0f0f0;
51
+ min-height: 400px;
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ border: 1px solid var(--border);
56
+ box-shadow: var(--shadow);
57
+ }
58
+ .result-image {
59
+ max-width: 100%;
60
+ height: auto;
61
+ display: block;
62
+ }
63
+ .character-item {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 12px;
67
+ padding: 12px !important;
68
+ transition: background 0.2s;
69
+ }
70
+ .character-item:hover {
71
+ background-color: #f0f7ff;
72
+ }
73
+ .character-item-img {
74
+ width: 50px;
75
+ height: 50px;
76
+ border-radius: 8px;
77
+ object-fit: cover;
78
+ background: #eee;
79
+ flex-shrink: 0;
80
+ }
81
+ .character-item-info {
82
+ flex-grow: 1;
83
+ overflow: hidden;
84
+ }
85
+ .character-item-name {
86
+ font-weight: 600;
87
+ font-size: 14px;
88
+ color: var(--text-primary);
89
+ margin-bottom: 2px;
90
+ }
91
+ .character-item-desc {
92
+ font-size: 12px;
93
+ color: var(--text-secondary);
94
+ white-space: nowrap;
95
+ text-overflow: ellipsis;
96
+ overflow: hidden;
97
+ }
98
+ .character-img-gallery {
99
+ display: flex;
100
+ gap: 4px;
101
+ margin-top: 4px;
102
+ overflow-x: auto;
103
+ padding-bottom: 4px;
104
+ }
105
+ .character-img-gallery img {
106
+ width: 30px;
107
+ height: 30px;
108
+ border-radius: 4px;
109
+ object-fit: cover;
110
+ cursor: pointer;
111
+ border: 1px solid transparent;
112
+ }
113
+ .character-img-gallery img:hover {
114
+ border-color: var(--accent);
115
+ }
116
+ </style>
117
+ {% endblock %}
118
+
119
+ {% block content %}
120
+ <!-- ์ƒ๋‹จ ํ—ค๋” ์˜์—ญ -->
121
+ <div class="dashboard-header d-flex justify-content-between align-items-center mb-4">
122
+ <div>
123
+ <h1 class="title-main mb-1">AI ์บ๋ฆญํ„ฐ ์ œ์ž‘</h1>
124
+ <p class="text-meta mb-0">์›์ž‘ ์„ค์ •์„ ๋ฐ”ํƒ•์œผ๋กœ ์บ๋ฆญํ„ฐ์˜ ๋น„์ฃผ์–ผ์„ ์„ค๊ณ„ํ•˜๊ณ  ์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. <br />
125
+ * '์บ๋ฆญํ„ฐ ์ƒ์„ฑ ํ”„๋กฌํ”„ํŠธ'์— ์ž์ฃผ ์‚ฌ์šฉํ•˜์‹œ๋Š” ๋ช…๋ น์–ด๋‚˜ ์„ค์ •์„ ์ €์žฅํ•˜์‹ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ฐธ์กฐํ•˜๋Š” ์ž‘ํ’ˆ๋“ค์˜ ๊ฒฝ์šฐ ์ €์žฅ๋œ ์ •๋ณด์— ์ถ”๊ฐ€๋˜์–ด ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค. <br />
126
+ * ํ”„๋กฌํ”„ํŠธ๋งŒ ๊ฐ€์ง€๊ณ  ์บ๋ฆญํ„ฐ๋ฅผ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. '์บ๋ฆญํ„ฐ ์ƒ์„ฑ ํ”„๋กฌํ”„ํŠธ'์— ์›ํ•˜๋Š” ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ž…๋ ฅ ํ›„ '์บ๋ฆญํ„ฐ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์‹œ์ž‘' ๋ฒ„ํŠผ์„ ๋ˆŒ๋Ÿฌ์ฃผ์„ธ์š”. '์ƒ์„ฑ ๋ชจ๋ธ'๋กœ ์„ ํƒํ•œ AI๊ฐ€ ์บ๋ฆญํ„ฐ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. <br />
127
+ * ์›์ž‘ ์›น์†Œ์„ค ์บ๋ฆญํ„ฐ๋ฅผ ๋ถ„์„ ํ›„ ์ดˆ์•ˆ์„ ์ œ์ž‘ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. <br />
128
+ 1. '์ฐธ์กฐ ์›น์†Œ์„ค ํ”„๋กœ์ ํŠธ'์—์„œ ์›ํ•˜๋Š” ์ž‘ํ’ˆ์„ ์„ ํƒํ•˜์„ธ์š”. ํ•ด๋‹น ์ž‘ํ’ˆ ์บ๋ฆญํ„ฐ ๋ชฉ๋ก์— '์บ๋ฆญํ„ฐ ๋ชฉ๋ก'์— ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค. <br />
129
+ 2. '์บ๋ฆญํ„ฐ ๋ชฉ๋ก'์—์„œ ์›ํ•˜๋Š” ์บ๋ฆญํ„ฐ๋ฅผ ํด๋ฆญํ•ด ์ฃผ์„ธ์š”. '๋ถ„์„ ๋ชจ๋ธ'์—์„œ ์„ ํƒํ•œ AI๊ฐ€ ์ž‘ํ’ˆ ์† ์บ๋ฆญํ„ฐ๋ฅผ ๋ถ„์„ ํ›„ '์บ๋ฆญํ„ฐ ์ƒ์„ฑ ํ”„๋กฌํ”„ํŠธ'์— ์บ๋ฆญํ„ฐ ์ •๋ณด๋ฅผ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
130
+ </p>
131
+ </div>
132
+ </div>
133
+
134
+ <!-- AI ์„ค์ • ๋ฐ” -->
135
+ <div class="clean-card mb-4 py-2 px-4 d-flex align-items-center gap-4" style="cursor: default;">
136
+ <div class="d-flex align-items-center gap-2">
137
+ <label for="analysisModelSelect" class="small fw-bold text-secondary mb-0">
138
+ <i class="fas fa-search me-1 text-primary"></i> ๋ถ„์„ ๋ชจ๋ธ:
139
+ </label>
140
+ <select id="analysisModelSelect" class="form-select form-select-sm border-0 bg-transparent shadow-none" style="width: auto; min-width: 180px; font-weight: 600;">
141
+ <option value="">๋ชจ๋ธ ๋กœ๋”ฉ ์ค‘...</option>
142
+ </select>
143
+ </div>
144
+ <div class="vr"></div>
145
+ <div class="d-flex align-items-center gap-2">
146
+ <label for="modelSelect" class="small fw-bold text-secondary mb-0">
147
+ <i class="fas fa-paint-brush me-1 text-success"></i> ์ƒ์„ฑ ๋ชจ๋ธ:
148
+ </label>
149
+ <select id="modelSelect" class="form-select form-select-sm border-0 bg-transparent shadow-none" style="width: auto; min-width: 180px; font-weight: 600;">
150
+ <option value="">๋ชจ๋ธ ๋กœ๋”ฉ ์ค‘...</option>
151
+ </select>
152
+ </div>
153
+ <button class="btn btn-sm text-muted p-0 hover-rotate" onclick="loadModels()">
154
+ <i class="fas fa-sync-alt"></i>
155
+ </button>
156
+ </div>
157
+
158
+ <div class="row g-4 align-items-stretch mb-5">
159
+ <!-- ์™ผ์ชฝ: ํ”„๋กฌํ”„ํŠธ ์ž…๋ ฅ -->
160
+ <div class="col-lg-8 gen-input-column">
161
+ <div class="clean-card h-100 p-4">
162
+ <div class="d-flex justify-content-between align-items-center mb-3">
163
+ <h2 class="h5 fw-bold mb-0">์บ๋ฆญํ„ฐ ์ƒ์„ฑ ํ”„๋กฌํ”„ํŠธ</h2>
164
+ <div class="d-flex gap-2">
165
+ <button class="btn btn-sm btn-outline-primary" onclick="saveGlobalPrompt()">
166
+ <i class="fas fa-save me-1"></i> ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์ €์žฅ
167
+ </button>
168
+ </div>
169
+ </div>
170
+ <textarea id="genPrompt" class="form-control border-0 bg-light p-4"
171
+ style="border-radius: 12px; font-size: 14px; line-height: 1.6;"
172
+ placeholder="์ƒ์„ฑํ•  ์บ๋ฆญํ„ฐ์˜ ์™ธ๋ชจ, ๋ณต์žฅ, ๋ถ„์œ„๊ธฐ ๋“ฑ์„ ์ƒ์„ธํžˆ ์ž…๋ ฅํ•˜์„ธ์š”."></textarea>
173
+
174
+ <div class="mt-4">
175
+ <button id="generateBtn" class="db-btn db-btn-primary w-100 py-3 fw-bold" onclick="generateCharacter()">
176
+ <i class="fas fa-wand-sparkles me-2"></i> ์บ๋ฆญํ„ฐ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์‹œ์ž‘
177
+ </button>
178
+ </div>
179
+ </div>
180
+ </div>
181
+
182
+ <!-- ์˜ค๋ฅธ์ชฝ: ์„ค์ • ๋ฐ ์ฐธ์กฐ -->
183
+ <div class="col-lg-4">
184
+ <div class="clean-card p-4 h-100">
185
+ <h2 class="h5 fw-bold mb-4">์ฐธ์กฐ ์„ค์ •</h2>
186
+
187
+ <div class="mb-4">
188
+ <label class="form-label small fw-bold text-secondary">์ฐธ์กฐ ์›น์†Œ์„ค ํ”„๋กœ์ ํŠธ</label>
189
+ <select id="projectSelect" class="form-select border-0 shadow-sm mb-2" onchange="loadCharacters(this.value)">
190
+ <option value="">-- ํ”„๋กœ์ ํŠธ ์„ ํƒ --</option>
191
+ </select>
192
+ <p class="text-muted small mb-0"><i class="fas fa-info-circle me-1"></i> ํ”„๋กœ์ ํŠธ๋ฅผ ์„ ํƒํ•˜๋ฉด ๋“ฑ๋ก๋œ ๋ฉ”์ธ ์บ๋ฆญํ„ฐ ๋ชฉ๋ก์ด ๋‚˜ํƒ€๋‚ฉ๋‹ˆ๋‹ค.</p>
193
+ </div>
194
+
195
+ <div class="mb-4">
196
+ <label class="form-label small fw-bold text-secondary">์บ๋ฆญํ„ฐ ๋ชฉ๋ก</label>
197
+ <div id="characterList" class="bg-light border rounded p-2" style="max-height: 300px; overflow-y: auto; min-height: 100px;">
198
+ <p class="text-muted small mb-0 p-2 text-center">ํ”„๋กœ์ ํŠธ๋ฅผ ๋จผ์ € ์„ ํƒํ•ด์ฃผ์„ธ์š”.</p>
199
+ </div>
200
+ </div>
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ <!-- ํ•˜๋‹จ ๊ฒฐ๊ณผ ์˜์—ญ -->
206
+ <div id="resultArea" class="d-none mb-5">
207
+ <div class="clean-card p-4">
208
+ <div class="d-flex justify-content-between align-items-center mb-4">
209
+ <h2 class="h5 fw-bold mb-0">์บ๋ฆญํ„ฐ ์ƒ์„ฑ ๊ฒฐ๊ณผ</h2>
210
+ <a id="downloadLink" href="#" class="db-btn db-btn-outline py-2" download>
211
+ <i class="fas fa-download"></i> ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ
212
+ </a>
213
+ </div>
214
+ <div class="result-image-container" id="imageContainer">
215
+ <div class="text-center py-5">
216
+ <div class="spinner-border text-primary mb-3" role="status"></div>
217
+ <p class="text-muted mb-0" id="loadingText">์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ๋งŒ ๊ธฐ๋‹ค๋ ค์ฃผ์„ธ์š”...</p>
218
+ </div>
219
+ </div>
220
+ </div>
221
+ </div>
222
+
223
+ <script>
224
+ let currentFileId = null;
225
+ const defaultAnalysisModel = "{{ default_analysis_model }}";
226
+
227
+ async function loadModels() {
228
+ const genSelect = document.getElementById('modelSelect');
229
+ const analysisSelect = document.getElementById('analysisModelSelect');
230
+ try {
231
+ const response = await fetch('/api/ollama/models?all=true');
232
+ const data = await response.json();
233
+
234
+ if (data.models && data.models.length > 0) {
235
+ const optionsHtml = ['<option value="">๋ชจ๋ธ ์„ ํƒ...</option>'];
236
+ const analysisOptionsHtml = ['<option value="">๋ชจ๋ธ ์„ ํƒ...</option>'];
237
+
238
+ // Gemini ๋ชจ๋ธ ๊ทธ๋ฃน
239
+ const geminiModels = data.models.filter(m => m.type === 'gemini');
240
+ if (geminiModels.length > 0) {
241
+ optionsHtml.push('<optgroup label="โœจ Google Gemini">');
242
+ analysisOptionsHtml.push('<optgroup label="โœจ Google Gemini">');
243
+ geminiModels.forEach(m => {
244
+ const label = m.name.startsWith('gemini:') ? m.name.substring(7) : m.name;
245
+ const genSelected = m.name.includes('gemini-3-pro-image-preview') ? 'selected' : '';
246
+
247
+ // ๏ฟฝ๏ฟฝ๏ฟฝ์„ ๋ชจ๋ธ ๊ธฐ๋ณธ๊ฐ’ ์„ค์ •
248
+ let analysisSelected = '';
249
+ if (defaultAnalysisModel) {
250
+ if (m.name === defaultAnalysisModel || m.name === `gemini:${defaultAnalysisModel}`) {
251
+ analysisSelected = 'selected';
252
+ }
253
+ } else if (m.name.includes('gemini-1.5-flash')) {
254
+ analysisSelected = 'selected';
255
+ }
256
+
257
+ optionsHtml.push(`<option value="${m.name}" ${genSelected}>${label}</option>`);
258
+ analysisOptionsHtml.push(`<option value="${m.name}" ${analysisSelected}>${label}</option>`);
259
+ });
260
+ optionsHtml.push('</optgroup>');
261
+ analysisOptionsHtml.push('</optgroup>');
262
+ }
263
+
264
+ // Ollama ๋ชจ๋ธ ๊ทธ๋ฃน
265
+ const ollamaModels = data.models.filter(m => m.type !== 'gemini');
266
+ if (ollamaModels.length > 0) {
267
+ optionsHtml.push('<optgroup label="๐Ÿค– Ollama (Local)">');
268
+ analysisOptionsHtml.push('<optgroup label="๐Ÿค– Ollama (Local)">');
269
+ ollamaModels.forEach(m => {
270
+ let analysisSelected = (m.name === defaultAnalysisModel) ? 'selected' : '';
271
+ optionsHtml.push(`<option value="${m.name}">${m.name}</option>`);
272
+ analysisOptionsHtml.push(`<option value="${m.name}" ${analysisSelected}>${m.name}</option>`);
273
+ });
274
+ optionsHtml.push('</optgroup>');
275
+ analysisOptionsHtml.push('</optgroup>');
276
+ }
277
+
278
+ genSelect.innerHTML = optionsHtml.join('');
279
+ analysisSelect.innerHTML = analysisOptionsHtml.join('');
280
+ }
281
+ } catch (error) {
282
+ console.error('๋ชจ๋ธ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
283
+ genSelect.innerHTML = '<option value="">๋กœ๋“œ ์‹คํŒจ</option>';
284
+ analysisSelect.innerHTML = '<option value="">๋กœ๋“œ ์‹คํŒจ</option>';
285
+ }
286
+ }
287
+
288
+ async function loadNovelProjects() {
289
+ const select = document.getElementById('projectSelect');
290
+ try {
291
+ const res = await fetch('/api/files?sort=uploaded_at_desc&public_only=true');
292
+ const data = await res.json();
293
+
294
+ if (data.files && data.files.length > 0) {
295
+ data.files.forEach(f => {
296
+ const opt = document.createElement('option');
297
+ opt.value = f.id;
298
+ opt.textContent = f.original_filename;
299
+ select.appendChild(opt);
300
+ });
301
+ }
302
+ } catch (e) {
303
+ console.error("Project load error:", e);
304
+ }
305
+ }
306
+
307
+ async function loadCharacters(fileId) {
308
+ if (!fileId) return;
309
+ currentFileId = fileId;
310
+ const list = document.getElementById('characterList');
311
+ const promptArea = document.getElementById('genPrompt');
312
+ list.innerHTML = '<div class="text-center p-3"><div class="spinner-border spinner-border-sm text-primary"></div></div>';
313
+
314
+ try {
315
+ const res = await fetch('/creation/api/character/extract-main', {
316
+ method: 'POST',
317
+ headers: {'Content-Type': 'application/json'},
318
+ body: JSON.stringify({ file_id: fileId })
319
+ });
320
+ const data = await res.json();
321
+
322
+ // ์ „์—ญ ๋ณ€์ˆ˜์— ํ”„๋กœ์ ํŠธ ๋ฐฐ๊ฒฝ ์ •๋ณด ์ €์žฅ (๋ถ„์„ ์‹œ ์‚ฌ์šฉ)
323
+ window.projectContext = `์„ธ๊ณ„๊ด€: ${data.world_view || '์ •๋ณด ์—†์Œ'}\n์ค„๊ฑฐ๋ฆฌ: ${data.story || '์ •๋ณด ์—†์Œ'}\n๊ธฐํƒ€: ${data.others || '์ •๋ณด ์—†์Œ'}`;
324
+
325
+ // 1. Parent Chunk ์ •๋ณด(์„ธ๊ณ„๊ด€, ๊ธฐํƒ€)๋ฅผ ํ”„๋กฌํ”„ํŠธ์— ์ถ”๊ฐ€
326
+ if (data.world_view || data.others) {
327
+ let contextPrompt = '';
328
+ if (data.world_view) contextPrompt += `### ์„ธ๊ณ„๊ด€ ์„ค๋ช…\n${data.world_view}\n\n`;
329
+ if (data.others) contextPrompt += `### ๊ธฐํƒ€ ์„ค์ •\n${data.others}\n\n`;
330
+
331
+ if (confirm("์„ ํƒํ•œ ํ”„๋กœ์ ํŠธ์˜ '์„ธ๊ณ„๊ด€ ์„ค๋ช…' ๋ฐ '๊ธฐํƒ€ ์„ค์ •'์„ ํ”„๋กฌํ”„ํŠธ์— ์ถ”๊ฐ€ํ• ๊นŒ์š”?")) {
332
+ promptArea.value = contextPrompt + promptArea.value;
333
+ }
334
+ }
335
+
336
+ if (data.characters && data.characters.length > 0) {
337
+ list.innerHTML = data.characters.map((c, idx) => {
338
+ const mainImg = c.image_path ? `/uploads/${c.image_path}` : '';
339
+ const hasImages = c.images && c.images.length > 0;
340
+
341
+ return `
342
+ <div class="border-bottom character-item cursor-pointer"
343
+ data-char-index="${idx}"
344
+ onclick='applyCharacterPrompt(${idx})'>
345
+ ${mainImg ? `<img src="${mainImg}" class="character-item-img">` : `<div class="character-item-img d-flex align-items-center justify-content-center text-muted"><i class="fas fa-user"></i></div>`}
346
+ <div class="character-item-info">
347
+ <div class="character-item-name">${c.name}</div>
348
+ <div class="character-item-desc text-muted small">${c.description || '์„ค๋ช… ์—†์Œ'}</div>
349
+ ${hasImages ? `
350
+ <div class="character-img-gallery mt-1">
351
+ ${c.images.map(img => `<img src="/uploads/${img.image_path}" onclick="event.stopPropagation(); window.open('/uploads/${img.image_path}', '_blank')" title="์ด๋ฏธ์ง€ ํฌ๊ฒŒ ๋ณด๊ธฐ">`).join('')}
352
+ </div>
353
+ ` : ''}
354
+ </div>
355
+ </div>
356
+ `;
357
+ }).join('');
358
+
359
+ // ์ „์—ญ ๋ณ€์ˆ˜์— ์บ๋ฆญํ„ฐ ๋ฐ์ดํ„ฐ ์ €์žฅ
360
+ window.extractedCharacters = data.characters;
361
+ } else {
362
+ list.innerHTML = '<p class="text-muted small mb-0 p-2 text-center">๋ถ„์„๋œ ์บ๋ฆญํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</p>';
363
+ }
364
+ } catch (e) {
365
+ list.innerHTML = '<p class="text-danger small mb-0 p-2 text-center">๋กœ๋“œ ์‹คํŒจ</p>';
366
+ }
367
+ }
368
+
369
+ async function applyCharacterPrompt(index) {
370
+ const c = window.extractedCharacters[index];
371
+ if (!c) return;
372
+
373
+ const name = c.name;
374
+ const desc = c.description;
375
+ const promptArea = document.getElementById('genPrompt');
376
+ const analysisModel = document.getElementById('analysisModelSelect').value;
377
+
378
+ if (!analysisModel) {
379
+ alert("๋ถ„์„ ๋ชจ๋ธ์„ ๋จผ์ € ์„ ํƒํ•ด์ฃผ์„ธ์š”.");
380
+ return;
381
+ }
382
+
383
+ if (confirm(`'${name}' ์บ๋ฆญํ„ฐ ์„ค์ •์„ ๋ถ„์„ํ•˜์—ฌ ํ”„๋กฌํ”„ํŠธ์— ์ ์šฉํ• ๊นŒ์š”?\n(์„ ํƒํ•œ ๋ถ„์„ ๋ชจ๋ธ: ${analysisModel})`)) {
384
+ const originalBtnText = document.querySelector(`.character-item[data-char-index="${index}"]`).innerHTML;
385
+ const charItem = document.querySelector(`.character-item[data-char-index="${index}"]`);
386
+
387
+ charItem.innerHTML = `<div class="fw-bold small text-primary"><span class="spinner-border spinner-border-sm me-1"></span>๋ถ„์„ ์ค‘...</div>`;
388
+ charItem.style.pointerEvents = 'none';
389
+
390
+ try {
391
+ const res = await fetch('/creation/api/character/analyze', {
392
+ method: 'POST',
393
+ headers: {'Content-Type': 'application/json'},
394
+ body: JSON.stringify({
395
+ name: name,
396
+ description: desc,
397
+ context: window.projectContext || '',
398
+ model_name: analysisModel
399
+ })
400
+ });
401
+ const data = await res.json();
402
+
403
+ if (data.success) {
404
+ const analyzedPrompt = data.analysis;
405
+ if (promptArea.value.trim()) {
406
+ promptArea.value = promptArea.value + "\n\n" + analyzedPrompt;
407
+ } else {
408
+ promptArea.value = analyzedPrompt;
409
+ }
410
+ // ํ”„๋กฌํ”„ํŠธ ์˜์—ญ์œผ๋กœ ์Šคํฌ๋กค
411
+ promptArea.scrollIntoView({ behavior: 'smooth' });
412
+ } else {
413
+ alert("๋ถ„์„ ์‹คํŒจ: " + data.error);
414
+ }
415
+ } catch (e) {
416
+ alert("ํ†ต์‹  ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.");
417
+ } finally {
418
+ charItem.innerHTML = originalBtnText;
419
+ charItem.style.pointerEvents = 'auto';
420
+ }
421
+ }
422
+ }
423
+
424
+ async function generateCharacter() {
425
+ const prompt = document.getElementById('genPrompt').value.trim();
426
+ const model = document.getElementById('modelSelect').value;
427
+ const btn = document.getElementById('generateBtn');
428
+ const resultArea = document.getElementById('resultArea');
429
+ const imageContainer = document.getElementById('imageContainer');
430
+
431
+ if (!prompt) return alert("ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.");
432
+ if (!model) return alert("๋ชจ๋ธ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.");
433
+
434
+ btn.disabled = true;
435
+ btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>์ด๋ฏธ์ง€ ์ƒ์„ฑ ์ค‘...';
436
+
437
+ resultArea.classList.remove('d-none');
438
+ imageContainer.innerHTML = `
439
+ <div class="text-center py-5">
440
+ <div class="spinner-border text-primary mb-3" role="status"></div>
441
+ <p class="text-muted mb-0">์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ๋งŒ ๊ธฐ๋‹ค๋ ค์ฃผ์„ธ์š”...</p>
442
+ </div>
443
+ `;
444
+
445
+ // ๊ฒฐ๊ณผ ์˜์—ญ์œผ๋กœ ์Šคํฌ๋กค
446
+ resultArea.scrollIntoView({ behavior: 'smooth' });
447
+
448
+ try {
449
+ const res = await fetch('/creation/api/generate-image', {
450
+ method: 'POST',
451
+ headers: {'Content-Type': 'application/json'},
452
+ body: JSON.stringify({
453
+ prompt: prompt,
454
+ model_name: model
455
+ })
456
+ });
457
+
458
+ const data = await res.json();
459
+ if (data.success) {
460
+ imageContainer.innerHTML = `<img src="${data.image_url}" class="result-image" alt="Generated Character">`;
461
+ document.getElementById('downloadLink').href = data.image_url;
462
+ } else {
463
+ alert("์ƒ์„ฑ ์‹คํŒจ: " + (data.error || "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜"));
464
+ imageContainer.innerHTML = '<p class="text-danger small p-3">์ด๋ฏธ์ง€ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.</p>';
465
+ }
466
+ } catch (e) {
467
+ alert("ํ†ต์‹  ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.");
468
+ imageContainer.innerHTML = '';
469
+ } finally {
470
+ btn.disabled = false;
471
+ btn.innerHTML = '<i class="fas fa-wand-sparkles me-2"></i> ์บ๋ฆญํ„ฐ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์‹œ์ž‘';
472
+ }
473
+ }
474
+
475
+ async function loadGlobalPrompt() {
476
+ try {
477
+ const res = await fetch('/creation/api/character/global-prompt');
478
+ const data = await res.json();
479
+ if (data.prompt) {
480
+ document.getElementById('genPrompt').value = data.prompt;
481
+ }
482
+ } catch (e) {}
483
+ }
484
+
485
+ async function saveGlobalPrompt() {
486
+ const prompt = document.getElementById('genPrompt').value;
487
+ try {
488
+ const res = await fetch('/creation/api/character/global-prompt', {
489
+ method: 'POST',
490
+ headers: {'Content-Type': 'application/json'},
491
+ body: JSON.stringify({ prompt: prompt })
492
+ });
493
+ if (res.ok) alert("์บ๋ฆญํ„ฐ ์ œ์ž‘ ๊ธฐ๋ณธ ํ”„๋กฌํ”„ํŠธ๋กœ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.");
494
+ } catch (e) {
495
+ alert("์ €์žฅ ์‹คํŒจ");
496
+ }
497
+ }
498
+
499
+ document.addEventListener('DOMContentLoaded', () => {
500
+ loadModels();
501
+ loadNovelProjects();
502
+ loadGlobalPrompt();
503
+ });
504
+ </script>
505
+ {% endblock %}
templates/webtoon_conti_dashboard.html CHANGED
@@ -110,9 +110,13 @@
110
  border: none;
111
  box-shadow: 0 4px 10px rgba(26, 115, 232, 0.3);
112
  }
113
- .btn-generate:hover {
114
- box-shadow: 0 6px 15px rgba(26, 115, 232, 0.4);
115
- transform: translateY(-1px);
 
 
 
 
116
  }
117
  </style>
118
  {% endblock %}
@@ -174,9 +178,9 @@
174
  </div>
175
  </div>
176
 
177
- <div class="row g-4">
178
- <div class="col-lg-8">
179
- <textarea id="novelText" class="form-control border-0 bg-light p-4" rows="10"
180
  style="border-radius: 12px; font-size: 14px; line-height: 1.6;"
181
  placeholder="์›น์†Œ์„ค์˜ ํŠน์ • ์žฅ๋ฉด์ด๋‚˜ ์—ํ”ผ์†Œ๋“œ ํ…์ŠคํŠธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”."></textarea>
182
  </div>
@@ -188,12 +192,35 @@
188
  <option value="">-- ํ”„๋กœ์ ํŠธ ์„ ํƒ --</option>
189
  </select>
190
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  <div class="mb-4">
192
- <label class="form-label small fw-bold text-secondary">์—ํ”ผ์†Œ๋“œ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ</label>
193
- <select id="episodeSelect" class="form-select border-0 shadow-sm" disabled onchange="loadEpisodeContent(this.value)">
194
- <option value="">-- ์—ํ”ผ์†Œ๋“œ ์„ ํƒ --</option>
195
- </select>
 
 
 
 
196
  </div>
 
197
  <button class="db-btn db-btn-primary w-100 py-3 fw-bold" onclick="startExtraction()">
198
  <i class="fas fa-wand-sparkles me-2"></i> ๋ถ„์„ ๋ฐ ์ฝ˜ํ‹ฐ ์ƒ์„ฑ ์‹œ์ž‘
199
  </button>
@@ -352,7 +379,7 @@ document.getElementById('genModelSelect').addEventListener('change', function()
352
 
353
  async function loadNovelProjects() {
354
  try {
355
- const res = await fetch('/api/files?sort=uploaded_at_desc');
356
  const data = await res.json();
357
  const files = data.files || [];
358
  const select = document.getElementById('projectSelect');
@@ -370,43 +397,136 @@ async function loadNovelProjects() {
370
  }
371
 
372
  async function loadEpisodes(fileId) {
373
- const select = document.getElementById('episodeSelect');
374
- select.innerHTML = '<option value="">-- ์—ํ”ผ์†Œ๋“œ ์„ ํƒ --</option>';
375
 
376
  currentFileId = fileId;
377
 
378
  if (!fileId) {
379
- select.disabled = true;
380
  return;
381
  }
382
 
383
  try {
384
- const res = await fetch(`/api/analysis/episodes/${fileId}`);
 
 
385
  const data = await res.json();
386
- const episodes = data.episodes || [];
387
 
388
- episodes.forEach(ep => {
389
- const opt = document.createElement('option');
390
- opt.value = ep.id;
391
- opt.textContent = ep.episode_title;
392
- select.appendChild(opt);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  });
394
- select.disabled = false;
 
 
 
 
 
 
 
 
 
 
 
 
395
  } catch (e) {
396
  console.error("Episode load error:", e);
 
397
  }
398
  }
399
 
400
- async function loadEpisodeContent(episodeId) {
401
- if (!episodeId) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
402
  try {
403
- const res = await fetch(`/api/analysis/episode/${episodeId}`);
404
  const data = await res.json();
405
- if (data.analysis_content) {
406
- document.getElementById('novelText').value = data.analysis_content;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
  }
408
  } catch (e) {
409
- console.error("Content load error:", e);
410
  }
411
  }
412
 
@@ -416,10 +536,17 @@ async function startExtraction() {
416
 
417
  const analysisModel = document.getElementById('analysisModelSelect').value;
418
  const genModel = document.getElementById('genModelSelect').value;
 
 
419
 
420
  if (!analysisModel) return alert("๋ถ„์„ ๋ชจ๋ธ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.");
421
  if (!genModel) return alert("์ฝ˜ํ‹ฐ ์ƒ์„ฑ ๋ชจ๋ธ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.");
422
 
 
 
 
 
 
423
  // ์ฐธ์กฐ ํ”„๋กœ์ ํŠธ ํ™•์ธ
424
  if (!currentFileId && !document.getElementById('projectSelect').value) {
425
  if (!confirm("์ฐธ์กฐํ•  ์›น์†Œ์„ค ํ”„๋กœ์ ํŠธ๊ฐ€ ์„ ํƒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์บ๋ฆญํ„ฐ/๋ฐฐ๊ฒฝ ์„ค์ • ์—†์ด ๋ถ„์„์„ ์ง„ํ–‰ํ• ๊นŒ์š”?\n(ํ”„๋กœ์ ํŠธ๋ฅผ ์„ ํƒํ•˜๋ฉด ๋” ์ •ํ™•ํ•œ ์บ๋ฆญํ„ฐ ๋ฌ˜์‚ฌ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.)")) {
@@ -439,7 +566,10 @@ async function startExtraction() {
439
  body: JSON.stringify({
440
  title: "์ฝ˜ํ‹ฐ ๋ถ„์„: " + (text.substring(0, 15) + "..."),
441
  file_id: currentFileId || 0,
442
- description: text.substring(0, 100)
 
 
 
443
  })
444
  });
445
 
@@ -460,9 +590,13 @@ async function startExtraction() {
460
  body: JSON.stringify({
461
  text: text,
462
  file_id: currentFileId || 0,
 
 
463
  project_id: currentProjectId,
464
  analysis_model: analysisModel,
465
- gen_model: genModel
 
 
466
  })
467
  });
468
 
@@ -485,6 +619,16 @@ async function loadProjectDetails(projectId) {
485
  const res = await fetch(`/webtoon-conti/api/projects/${projectId}`);
486
  const data = await res.json();
487
 
 
 
 
 
 
 
 
 
 
 
488
  document.getElementById('panelListHeader').classList.remove('d-none');
489
  document.getElementById('panelListHeader').classList.add('d-flex');
490
  document.getElementById('panelCount').innerText = data.panels.length;
@@ -562,6 +706,7 @@ function renderSample(s, finalPath) {
562
 
563
  async function generateSamples(panelId) {
564
  const model = document.getElementById('genModelSelect').value;
 
565
  const container = document.getElementById(`samples-${panelId}`);
566
 
567
  const isDirectAI = model && (model.toLowerCase().includes('imagen') || model.toLowerCase().includes('gemini'));
@@ -580,7 +725,8 @@ async function generateSamples(panelId) {
580
  body: JSON.stringify({
581
  panel_id: panelId,
582
  sample_count: 3,
583
- model: model
 
584
  })
585
  });
586
 
@@ -709,6 +855,50 @@ async function openLoadModal() {
709
  modal.show();
710
  }
711
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712
  function loadProjectAndCloseModal(id) {
713
  const modalEl = document.getElementById('loadProjectModal');
714
  const modal = bootstrap.Modal.getInstance(modalEl);
@@ -725,6 +915,7 @@ function resetPage() {
725
  document.addEventListener('DOMContentLoaded', () => {
726
  loadModels();
727
  loadNovelProjects();
 
728
  });
729
  </script>
730
  {% endblock %}
 
110
  border: none;
111
  box-shadow: 0 4px 10px rgba(26, 115, 232, 0.3);
112
  }
113
+ .novel-input-column {
114
+ display: flex;
115
+ flex-direction: column;
116
+ }
117
+ #novelText {
118
+ flex-grow: 1;
119
+ min-height: 500px;
120
  }
121
  </style>
122
  {% endblock %}
 
178
  </div>
179
  </div>
180
 
181
+ <div class="row g-4 align-items-stretch">
182
+ <div class="col-lg-8 novel-input-column">
183
+ <textarea id="novelText" class="form-control border-0 bg-light p-4 h-100"
184
  style="border-radius: 12px; font-size: 14px; line-height: 1.6;"
185
  placeholder="์›น์†Œ์„ค์˜ ํŠน์ • ์žฅ๋ฉด์ด๋‚˜ ์—ํ”ผ์†Œ๋“œ ํ…์ŠคํŠธ๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”."></textarea>
186
  </div>
 
192
  <option value="">-- ํ”„๋กœ์ ํŠธ ์„ ํƒ --</option>
193
  </select>
194
  </div>
195
+ <div class="mb-3">
196
+ <label class="form-label small fw-bold text-secondary">ํšŒ์ฐจ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ (๋‹ค์ค‘ ์„ ํƒ ๊ฐ€๋Šฅ)</label>
197
+ <div id="episodeList" class="bg-white border rounded p-2" style="max-height: 200px; overflow-y: auto;">
198
+ <p class="text-muted small mb-0 p-2">ํ”„๋กœ์ ํŠธ๋ฅผ ๋จผ์ € ์„ ํƒํ•ด์ฃผ์„ธ์š”.</p>
199
+ </div>
200
+ </div>
201
+
202
+ <div class="mb-3">
203
+ <div class="d-flex justify-content-between align-items-center mb-1">
204
+ <label class="form-label small fw-bold text-secondary mb-0">๋ถ„์„ ์ฐธ์กฐ ํ”„๋กฌํ”„ํŠธ</label>
205
+ <button class="btn btn-sm p-0 text-primary" onclick="savePrompts()" title="ํ”„๋กฌํ”„ํŠธ ์ €์žฅ">
206
+ <i class="fas fa-save"></i>
207
+ </button>
208
+ </div>
209
+ <textarea id="analysisPrompt" class="form-control border-0 shadow-sm small" rows="3"
210
+ placeholder="๋ถ„์„ ์‹œ ์ฐธ๊ณ ํ•  ์ถ”๊ฐ€ ์ง€์นจ์„ ์ž…๋ ฅํ•˜์„ธ์š”."></textarea>
211
+ </div>
212
+
213
  <div class="mb-4">
214
+ <div class="d-flex justify-content-between align-items-center mb-1">
215
+ <label class="form-label small fw-bold text-secondary mb-0">๊ทธ๋ฆผ ์ฝ˜ํ‹ฐ์šฉ ํ”„๋กฌํ”„ํŠธ</label>
216
+ <button class="btn btn-sm p-0 text-primary" onclick="savePrompts()" title="ํ”„๋กฌํ”„ํŠธ ์ €์žฅ">
217
+ <i class="fas fa-save"></i>
218
+ </button>
219
+ </div>
220
+ <textarea id="genPrompt" class="form-control border-0 shadow-sm small" rows="3"
221
+ placeholder="์ด๋ฏธ์ง€ ์ƒ์„ฑ ์‹œ ์ฐธ๊ณ ํ•  ์ถ”๊ฐ€ ์Šคํƒ€์ผ ์ง€์นจ์„ ์ž…๋ ฅํ•˜์„ธ์š”."></textarea>
222
  </div>
223
+
224
  <button class="db-btn db-btn-primary w-100 py-3 fw-bold" onclick="startExtraction()">
225
  <i class="fas fa-wand-sparkles me-2"></i> ๋ถ„์„ ๋ฐ ์ฝ˜ํ‹ฐ ์ƒ์„ฑ ์‹œ์ž‘
226
  </button>
 
379
 
380
  async function loadNovelProjects() {
381
  try {
382
+ const res = await fetch('/api/files?sort=uploaded_at_desc&public_only=true');
383
  const data = await res.json();
384
  const files = data.files || [];
385
  const select = document.getElementById('projectSelect');
 
397
  }
398
 
399
  async function loadEpisodes(fileId) {
400
+ const listContainer = document.getElementById('episodeList');
401
+ listContainer.innerHTML = '<div class="text-center p-2"><div class="spinner-border spinner-border-sm text-primary"></div></div>';
402
 
403
  currentFileId = fileId;
404
 
405
  if (!fileId) {
406
+ listContainer.innerHTML = '<p class="text-muted small mb-0 p-2">ํ”„๋กœ์ ํŠธ๋ฅผ ๋จผ์ € ์„ ํƒํ•ด์ฃผ์„ธ์š”.</p>';
407
  return;
408
  }
409
 
410
  try {
411
+ // 1. ํšŒ์ฐจ๋ณ„ ๋ถ„์„ ์ •๋ณด(EpisodeAnalysis)์™€ GraphRAG(GraphEntity) ์ •๋ณด๋ฅผ ํ•จ๊ป˜ ๊ณ ๋ คํ•˜๊ธฐ ์œ„ํ•ด
412
+ // ๋จผ์ € GraphRAG API๋ฅผ ๏ฟฝ๏ฟฝ๏ฟฝํ•ด ์กด์žฌํ•˜๋Š” ํšŒ์ฐจ ๋ชฉ๋ก์„ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.
413
+ const res = await fetch(`/api/files/${fileId}/graph`);
414
  const data = await res.json();
 
415
 
416
+ // GraphRAG์— ๋“ฑ๋ก๋œ ํšŒ์ฐจ ๋ชฉ๋ก (episodes)
417
+ const graphEpisodes = data.episodes || [];
418
+
419
+ // 2. ๊ธฐ์กด EpisodeAnalysis API์—์„œ๋„ ํšŒ์ฐจ ๋ชฉ๋ก์„ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค.
420
+ const res2 = await fetch(`/api/analysis/episodes/${fileId}`);
421
+ const data2 = await res2.json();
422
+ const analysisEpisodes = data2.episodes || [];
423
+
424
+ // ๋‘ ์†Œ์Šค๋ฅผ ํ†ตํ•ฉํ•˜์—ฌ ์œ ๋‹ˆํฌํ•œ ํšŒ์ฐจ ๋ชฉ๋ก ์ƒ์„ฑ
425
+ // analysisEpisodes๋Š” [{id, episode_title, ...}] ํ˜•ํƒœ๊ณ , graphEpisodes๋Š” ['1ํ™”', '2ํ™”', ...] ํ˜•ํƒœ์ž„
426
+ const allEpisodes = [];
427
+
428
+ // ๋จผ์ € EpisodeAnalysis ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋ชฉ๋ก ๊ตฌ์„ฑ
429
+ analysisEpisodes.forEach(ep => {
430
+ allEpisodes.push({
431
+ id: ep.id,
432
+ title: ep.episode_title,
433
+ source: 'analysis'
434
+ });
435
+ });
436
+
437
+ // GraphRAG์—๋งŒ ์žˆ๊ณ  EpisodeAnalysis์—๋Š” ์—†๋Š” ํšŒ์ฐจ ์ถ”๊ฐ€ (ํ•„์š”์‹œ)
438
+ graphEpisodes.forEach(title => {
439
+ if (!allEpisodes.find(e => e.title === title)) {
440
+ allEpisodes.push({
441
+ id: `graph-${title}`,
442
+ title: title,
443
+ source: 'graph'
444
+ });
445
+ }
446
+ });
447
+
448
+ if (allEpisodes.length === 0) {
449
+ listContainer.innerHTML = '<p class="text-muted small mb-0 p-2">ํšŒ์ฐจ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</p>';
450
+ return;
451
+ }
452
+
453
+ // ์ž์—ฐ์–ด ์ •๋ ฌ (1ํ™”, 2ํ™”, 10ํ™” ์ˆœ์„œ ๋“ฑ)
454
+ allEpisodes.sort((a, b) => {
455
+ return a.title.localeCompare(b.title, undefined, {numeric: true, sensitivity: 'base'});
456
  });
457
+
458
+ listContainer.innerHTML = allEpisodes.map(ep => `
459
+ <div class="form-check p-1 ms-4">
460
+ <input class="form-check-input episode-checkbox" type="checkbox"
461
+ value="${ep.id}"
462
+ data-title="${ep.title}"
463
+ data-source="${ep.source}"
464
+ id="ep-${ep.id}" onchange="updateContentFromSelection()">
465
+ <label class="form-check-label small" for="ep-${ep.id}">
466
+ ${ep.title} ${ep.source === 'graph' ? '<span class="badge bg-info ms-1" style="font-size: 8px;">Graph</span>' : ''}
467
+ </label>
468
+ </div>
469
+ `).join('');
470
  } catch (e) {
471
  console.error("Episode load error:", e);
472
+ listContainer.innerHTML = '<p class="text-danger small mb-0 p-2">ํšŒ์ฐจ ๋กœ๋“œ ์‹คํŒจ</p>';
473
  }
474
  }
475
 
476
+ async function updateContentFromSelection() {
477
+ const checkedNodes = Array.from(document.querySelectorAll('.episode-checkbox:checked'));
478
+ if (checkedNodes.length === 0) {
479
+ return;
480
+ }
481
+
482
+ // ๊ฐ€์žฅ ์ตœ๊ทผ์— ์ฒดํฌ๋œ ๋…ธ๋“œ
483
+ const lastNode = checkedNodes[checkedNodes.length - 1];
484
+ const source = lastNode.getAttribute('data-source');
485
+ const id = lastNode.value;
486
+ const title = lastNode.getAttribute('data-title');
487
+
488
+ if (source === 'analysis') {
489
+ await loadEpisodeContent(id);
490
+ } else if (source === 'graph') {
491
+ await loadGraphEpisodeContent(currentFileId, title);
492
+ }
493
+ }
494
+
495
+ async function loadGraphEpisodeContent(fileId, episodeTitle) {
496
  try {
497
+ const res = await fetch(`/api/files/${fileId}/graph`);
498
  const data = await res.json();
499
+
500
+ const entities = data.entities_by_episode[episodeTitle] || {characters: [], locations: []};
501
+ const relationships = data.relationships_by_episode[episodeTitle] || [];
502
+ const events = data.events_by_episode[episodeTitle] || [];
503
+
504
+ let content = `[${episodeTitle} GraphRAG ๋ถ„์„ ๋‚ด์šฉ]\n\n`;
505
+
506
+ if (entities.characters.length > 0) {
507
+ content += "โ–  ์ฃผ์š” ์ธ๋ฌผ:\n" + entities.characters.map(c => `- ${c.entity_name}: ${c.description || ''} (์—ญํ• : ${c.role || '๋ฏธ์ •'})`).join('\n') + "\n\n";
508
+ }
509
+
510
+ if (events.length > 0) {
511
+ content += "โ–  ์ฃผ์š” ์‚ฌ๊ฑด:\n" + events.map(e => `- ${e.event_name}: ${e.description}`).join('\n') + "\n\n";
512
+ }
513
+
514
+ if (relationships.length > 0) {
515
+ content += "โ–  ๊ด€๊ณ„๋„:\n" + relationships.map(r => `- ${r.source} -> ${r.target}: ${r.description || r.relationship_type}`).join('\n') + "\n";
516
+ }
517
+
518
+ const textarea = document.getElementById('novelText');
519
+ if (textarea.value && !textarea.value.includes(episodeTitle)) {
520
+ if (confirm(`'${episodeTitle}'์˜ GraphRAG ๋ถ„์„ ๋‚ด์šฉ์„ ๊ธฐ์กด ํ…์ŠคํŠธ์— ์ถ”๊ฐ€ํ• ๊นŒ์š”?`)) {
521
+ textarea.value += "\n\n" + content;
522
+ } else {
523
+ textarea.value = content;
524
+ }
525
+ } else {
526
+ textarea.value = content;
527
  }
528
  } catch (e) {
529
+ console.error("Graph content load error:", e);
530
  }
531
  }
532
 
 
536
 
537
  const analysisModel = document.getElementById('analysisModelSelect').value;
538
  const genModel = document.getElementById('genModelSelect').value;
539
+ const analysisPrompt = document.getElementById('analysisPrompt').value;
540
+ const genPrompt = document.getElementById('genPrompt').value;
541
 
542
  if (!analysisModel) return alert("๋ถ„์„ ๋ชจ๋ธ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.");
543
  if (!genModel) return alert("์ฝ˜ํ‹ฐ ์ƒ์„ฑ ๋ชจ๋ธ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.");
544
 
545
+ // ์„ ํƒ๋œ ํšŒ์ฐจ ์ •๋ณด ๋ชฉ๋ก
546
+ const checkedNodes = Array.from(document.querySelectorAll('.episode-checkbox:checked'));
547
+ const episodeIds = checkedNodes.filter(n => n.getAttribute('data-source') === 'analysis').map(n => parseInt(n.value));
548
+ const graphEpisodeTitles = checkedNodes.filter(n => n.getAttribute('data-source') === 'graph').map(n => n.getAttribute('data-title'));
549
+
550
  // ์ฐธ์กฐ ํ”„๋กœ์ ํŠธ ํ™•์ธ
551
  if (!currentFileId && !document.getElementById('projectSelect').value) {
552
  if (!confirm("์ฐธ์กฐํ•  ์›น์†Œ์„ค ํ”„๋กœ์ ํŠธ๊ฐ€ ์„ ํƒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์บ๋ฆญํ„ฐ/๋ฐฐ๊ฒฝ ์„ค์ • ์—†์ด ๋ถ„์„์„ ์ง„ํ–‰ํ• ๊นŒ์š”?\n(ํ”„๋กœ์ ํŠธ๋ฅผ ์„ ํƒํ•˜๋ฉด ๋” ์ •ํ™•ํ•œ ์บ๋ฆญํ„ฐ ๋ฌ˜์‚ฌ๊ฐ€ ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.)")) {
 
566
  body: JSON.stringify({
567
  title: "์ฝ˜ํ‹ฐ ๋ถ„์„: " + (text.substring(0, 15) + "..."),
568
  file_id: currentFileId || 0,
569
+ episode_id: episodeIds.length > 0 ? episodeIds[0] : null,
570
+ description: text.substring(0, 100),
571
+ analysis_prompt: analysisPrompt,
572
+ gen_prompt: genPrompt
573
  })
574
  });
575
 
 
590
  body: JSON.stringify({
591
  text: text,
592
  file_id: currentFileId || 0,
593
+ episode_ids: episodeIds,
594
+ graph_episode_titles: graphEpisodeTitles, // GraphRAG ํšŒ์ฐจ ์ œ๋ชฉ๋“ค ์ถ”๊ฐ€
595
  project_id: currentProjectId,
596
  analysis_model: analysisModel,
597
+ gen_model: genModel,
598
+ analysis_prompt: analysisPrompt,
599
+ gen_prompt: genPrompt
600
  })
601
  });
602
 
 
619
  const res = await fetch(`/webtoon-conti/api/projects/${projectId}`);
620
  const data = await res.json();
621
 
622
+ // ํ”„๋กฌํ”„ํŠธ ์ •๋ณด ๋กœ๋“œ
623
+ if (data.project) {
624
+ if (data.project.analysis_prompt) {
625
+ document.getElementById('analysisPrompt').value = data.project.analysis_prompt;
626
+ }
627
+ if (data.project.gen_prompt) {
628
+ document.getElementById('genPrompt').value = data.project.gen_prompt;
629
+ }
630
+ }
631
+
632
  document.getElementById('panelListHeader').classList.remove('d-none');
633
  document.getElementById('panelListHeader').classList.add('d-flex');
634
  document.getElementById('panelCount').innerText = data.panels.length;
 
706
 
707
  async function generateSamples(panelId) {
708
  const model = document.getElementById('genModelSelect').value;
709
+ const genPrompt = document.getElementById('genPrompt').value;
710
  const container = document.getElementById(`samples-${panelId}`);
711
 
712
  const isDirectAI = model && (model.toLowerCase().includes('imagen') || model.toLowerCase().includes('gemini'));
 
725
  body: JSON.stringify({
726
  panel_id: panelId,
727
  sample_count: 3,
728
+ model: model,
729
+ gen_prompt: genPrompt
730
  })
731
  });
732
 
 
855
  modal.show();
856
  }
857
 
858
+ async function loadGlobalPrompts() {
859
+ try {
860
+ const res = await fetch('/webtoon-conti/api/global-prompts');
861
+ const data = await res.json();
862
+ if (data.analysis_prompt) document.getElementById('analysisPrompt').value = data.analysis_prompt;
863
+ if (data.gen_prompt) document.getElementById('genPrompt').value = data.gen_prompt;
864
+ } catch (e) {
865
+ console.error("Global prompts load error:", e);
866
+ }
867
+ }
868
+
869
+ async function savePrompts() {
870
+ const analysisPrompt = document.getElementById('analysisPrompt').value;
871
+ const genPrompt = document.getElementById('genPrompt').value;
872
+
873
+ try {
874
+ // 1. ๋จผ์ € ์ „์—ญ ์„ค์ •์œผ๋กœ ์ €์žฅ (ํ”„๋กœ์ ํŠธ ์œ ๋ฌด์™€ ๊ด€๊ณ„์—†์ด)
875
+ await fetch('/webtoon-conti/api/global-prompts', {
876
+ method: 'POST',
877
+ headers: {'Content-Type': 'application/json'},
878
+ body: JSON.stringify({
879
+ analysis_prompt: analysisPrompt,
880
+ gen_prompt: genPrompt
881
+ })
882
+ });
883
+
884
+ // 2. ํ˜„์žฌ ์ž‘์—… ์ค‘์ธ ํ”„๋กœ์ ํŠธ๊ฐ€ ์žˆ๋‹ค๋ฉด ํ”„๋กœ์ ํŠธ์—๋„ ์ €์žฅ
885
+ if (currentProjectId) {
886
+ await fetch(`/webtoon-conti/api/projects/${currentProjectId}/prompts`, {
887
+ method: 'PUT',
888
+ headers: {'Content-Type': 'application/json'},
889
+ body: JSON.stringify({
890
+ analysis_prompt: analysisPrompt,
891
+ gen_prompt: genPrompt
892
+ })
893
+ });
894
+ }
895
+
896
+ alert("ํ”„๋กฌํ”„ํŠธ ์„ค์ •์ด ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.");
897
+ } catch (e) {
898
+ alert("ํ”„๋กฌํ”„ํŠธ ์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: " + e.message);
899
+ }
900
+ }
901
+
902
  function loadProjectAndCloseModal(id) {
903
  const modalEl = document.getElementById('loadProjectModal');
904
  const modal = bootstrap.Modal.getInstance(modalEl);
 
915
  document.addEventListener('DOMContentLoaded', () => {
916
  loadModels();
917
  loadNovelProjects();
918
+ loadGlobalPrompts(); // ์ „์—ญ ํ”„๋กฌํ”„ํŠธ ๋ถˆ๋Ÿฌ์˜ค๊ธฐ ์ถ”๊ฐ€
919
  });
920
  </script>
921
  {% endblock %}
templates/webtoon_line_art_challenge.html ADDED
@@ -0,0 +1,648 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "creation_base.html" %}
2
+
3
+ {% block title %}์›นํˆฐ ์„ ํ™” ๋„์ „ - SOYMEDIA{% endblock %}
4
+
5
+ {% block nav %}
6
+ {% set admin_nav_title = '์›นํˆฐ ์„ ํ™” ๋„์ „' %}
7
+ {% set admin_nav_icon = '๐ŸŽจ' %}
8
+ {% include '_admin_nav.html' %}
9
+ {% endblock %}
10
+
11
+ {% block extra_css %}
12
+ <style>
13
+ .character-card {
14
+ border: 1px solid var(--border);
15
+ border-radius: 12px;
16
+ background: white;
17
+ margin-bottom: 1.5rem;
18
+ overflow: hidden;
19
+ transition: all 0.3s;
20
+ }
21
+ .character-card:hover {
22
+ box-shadow: 0 4px 15px rgba(0,0,0,0.1);
23
+ transform: translateY(-2px);
24
+ }
25
+ .character-header {
26
+ padding: 1rem 1.5rem;
27
+ background: #f8f9fa;
28
+ border-bottom: 1px solid var(--border);
29
+ display: flex;
30
+ justify-content: space-between;
31
+ align-items: center;
32
+ }
33
+ .character-body {
34
+ padding: 1.5rem;
35
+ }
36
+ .gen-input-column {
37
+ display: flex;
38
+ flex-direction: column;
39
+ }
40
+ #genPrompt {
41
+ flex-grow: 1;
42
+ min-height: 400px;
43
+ }
44
+ .result-image-container {
45
+ width: 100%;
46
+ max-width: 800px;
47
+ margin: 0 auto;
48
+ border-radius: 12px;
49
+ overflow: hidden;
50
+ background: #f0f0f0;
51
+ min-height: 400px;
52
+ display: flex;
53
+ align-items: center;
54
+ justify-content: center;
55
+ border: 1px solid var(--border);
56
+ box-shadow: var(--shadow);
57
+ }
58
+ .result-image {
59
+ max-width: 100%;
60
+ height: auto;
61
+ display: block;
62
+ }
63
+ .character-item {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 12px;
67
+ padding: 12px !important;
68
+ transition: background 0.2s;
69
+ }
70
+ .character-item:hover {
71
+ background-color: #f0f7ff;
72
+ }
73
+ .character-item-img {
74
+ width: 50px;
75
+ height: 50px;
76
+ border-radius: 8px;
77
+ object-fit: cover;
78
+ background: #eee;
79
+ flex-shrink: 0;
80
+ }
81
+ .character-item-info {
82
+ flex-grow: 1;
83
+ overflow: hidden;
84
+ }
85
+ .character-item-name {
86
+ font-weight: 600;
87
+ font-size: 14px;
88
+ color: var(--text-primary);
89
+ margin-bottom: 2px;
90
+ }
91
+ .character-item-desc {
92
+ font-size: 12px;
93
+ color: var(--text-secondary);
94
+ white-space: nowrap;
95
+ text-overflow: ellipsis;
96
+ overflow: hidden;
97
+ }
98
+ .character-img-gallery {
99
+ display: flex;
100
+ gap: 4px;
101
+ margin-top: 4px;
102
+ overflow-x: auto;
103
+ padding-bottom: 4px;
104
+ }
105
+ .character-img-gallery img {
106
+ width: 30px;
107
+ height: 30px;
108
+ border-radius: 4px;
109
+ object-fit: cover;
110
+ cursor: pointer;
111
+ border: 1px solid transparent;
112
+ }
113
+ .character-img-gallery img:hover {
114
+ border-color: var(--accent);
115
+ }
116
+
117
+ /* ๊ทธ๋ฆผ ์ฝ˜ํ‹ฐ ๊ฐค๋Ÿฌ๋ฆฌ ์Šคํƒ€์ผ */
118
+ .conty-gallery {
119
+ display: grid;
120
+ grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
121
+ gap: 12px;
122
+ margin-top: 12px;
123
+ }
124
+ .conty-item {
125
+ position: relative;
126
+ aspect-ratio: 1;
127
+ border-radius: 8px;
128
+ overflow: hidden;
129
+ border: 2px solid transparent;
130
+ cursor: pointer;
131
+ transition: all 0.2s;
132
+ }
133
+ .conty-item:hover {
134
+ transform: scale(1.02);
135
+ }
136
+ .conty-item.selected {
137
+ border-color: var(--accent);
138
+ box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.2);
139
+ }
140
+ .conty-item img {
141
+ width: 100%;
142
+ height: 100%;
143
+ object-fit: cover;
144
+ }
145
+ .conty-item .conty-remove {
146
+ position: absolute;
147
+ top: 4px;
148
+ right: 4px;
149
+ background: rgba(220, 35, 67, 0.8);
150
+ color: white;
151
+ border: none;
152
+ border-radius: 4px;
153
+ width: 20px;
154
+ height: 20px;
155
+ font-size: 12px;
156
+ display: flex;
157
+ align-items: center;
158
+ justify-content: center;
159
+ opacity: 0;
160
+ transition: opacity 0.2s;
161
+ }
162
+ .conty-item:hover .conty-remove {
163
+ opacity: 1;
164
+ }
165
+ .conty-selection-badge {
166
+ position: absolute;
167
+ bottom: 4px;
168
+ left: 4px;
169
+ background: var(--accent);
170
+ color: white;
171
+ padding: 2px 6px;
172
+ border-radius: 4px;
173
+ font-size: 10px;
174
+ font-weight: bold;
175
+ display: none;
176
+ }
177
+ .conty-item.selected .conty-selection-badge {
178
+ display: block;
179
+ }
180
+ </style>
181
+ {% endblock %}
182
+
183
+ {% block content %}
184
+ <!-- ์ƒ๋‹จ ํ—ค๋” ์˜์—ญ -->
185
+ <div class="dashboard-header d-flex justify-content-between align-items-center mb-4">
186
+ <div>
187
+ <h1 class="title-main mb-1">์›นํˆฐ ์„ ํ™” ๋„์ „</h1>
188
+ <p class="text-meta mb-0">์›นํˆฐ์˜ ์„ ํ™” ์Šคํƒ€์ผ์„ ์‹คํ—˜ํ•˜๊ณ  ๊ณ ํ€„๋ฆฌํ‹ฐ ๋ผ์ธ ์•„ํŠธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. <br />
189
+ * '์„ ํ™” ์ƒ์„ฑ ํ”„๋กฌํ”„ํŠธ'์— ์ž์ฃผ ์‚ฌ์šฉํ•˜์‹œ๋Š” ๋ช…๋ น์–ด๋‚˜ ์„ค์ •์„ ์ €์žฅํ•˜์‹ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. <br />
190
+ * ์บ๋ฆญํ„ฐ ๏ฟฝ๏ฟฝ์„ ๋ชจ๋ธ์„ ์‚ฌ์šฉํ•˜์—ฌ ์›์ž‘ ์„ค์ •์„ ๋ฐ”ํƒ•์œผ๋กœ ์ตœ์ ํ™”๋œ ์„ ํ™” ํ”„๋กฌํ”„ํŠธ๋ฅผ ๊ตฌ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
191
+ </p>
192
+ </div>
193
+ </div>
194
+
195
+ <!-- AI ์„ค์ • ๋ฐ” -->
196
+ <div class="clean-card mb-4 py-2 px-4 d-flex align-items-center gap-4" style="cursor: default;">
197
+ <div class="d-flex align-items-center gap-2">
198
+ <label for="analysisModelSelect" class="small fw-bold text-secondary mb-0">
199
+ <i class="fas fa-search me-1 text-primary"></i> ๋ถ„์„ ๋ชจ๋ธ:
200
+ </label>
201
+ <select id="analysisModelSelect" class="form-select form-select-sm border-0 bg-transparent shadow-none" style="width: auto; min-width: 180px; font-weight: 600;">
202
+ <option value="">๋ชจ๋ธ ๋กœ๋”ฉ ์ค‘...</option>
203
+ </select>
204
+ </div>
205
+ <div class="vr"></div>
206
+ <div class="d-flex align-items-center gap-2">
207
+ <label for="modelSelect" class="small fw-bold text-secondary mb-0">
208
+ <i class="fas fa-paint-brush me-1 text-success"></i> ์ƒ์„ฑ ๋ชจ๋ธ:
209
+ </label>
210
+ <select id="modelSelect" class="form-select form-select-sm border-0 bg-transparent shadow-none" style="width: auto; min-width: 180px; font-weight: 600;">
211
+ <option value="">๋ชจ๋ธ ๋กœ๋”ฉ ์ค‘...</option>
212
+ </select>
213
+ </div>
214
+ <button class="btn btn-sm text-muted p-0 hover-rotate" onclick="loadModels()">
215
+ <i class="fas fa-sync-alt"></i>
216
+ </button>
217
+ </div>
218
+
219
+ <div class="row g-4 align-items-stretch mb-5">
220
+ <!-- ์™ผ์ชฝ: ํ”„๋กฌํ”„ํŠธ ์ž…๋ ฅ -->
221
+ <div class="col-lg-8 gen-input-column">
222
+ <div class="clean-card h-100 p-4">
223
+ <div class="d-flex justify-content-between align-items-center mb-3">
224
+ <h2 class="h5 fw-bold mb-0">์„ ํ™” ์ƒ์„ฑ ํ”„๋กฌํ”„ํŠธ</h2>
225
+ <div class="d-flex gap-2">
226
+ <button class="btn btn-sm btn-outline-primary" onclick="saveGlobalPrompt()">
227
+ <i class="fas fa-save me-1"></i> ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์ €์žฅ
228
+ </button>
229
+ </div>
230
+ </div>
231
+ <textarea id="genPrompt" class="form-control border-0 bg-light p-4"
232
+ style="border-radius: 12px; font-size: 14px; line-height: 1.6; min-height: 300px;"
233
+ placeholder="์ƒ์„ฑํ•  ์„ ํ™”์˜ ์Šคํƒ€์ผ, ๊ตฌ๋„, ์บ๋ฆญํ„ฐ ํŠน์ง• ๋“ฑ์„ ์ž…๋ ฅํ•˜์„ธ์š”."></textarea>
234
+
235
+ <!-- ๊ทธ๋ฆผ ์ฝ˜ํ‹ฐ ์—…๋กœ๋“œ ๋ฐ ๊ด€๋ฆฌ ์„น์…˜ -->
236
+ <div class="mt-4 pt-4 border-top">
237
+ <div class="d-flex justify-content-between align-items-center mb-3">
238
+ <h3 class="h6 fw-bold mb-0"><i class="fas fa-images me-2 text-primary"></i>์‚ฌ์šฉํ•  ๊ทธ๋ฆผ ์ฝ˜ํ‹ฐ (์ฐธ์กฐ ์ด๋ฏธ์ง€)</h3>
239
+ <div class="d-flex gap-2">
240
+ <input type="file" id="contyUpload" class="d-none" accept="image/*" multiple onchange="uploadContyImages(this.files)">
241
+ <button class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('contyUpload').click()">
242
+ <i class="fas fa-upload me-1"></i> ์ฝ˜ํ‹ฐ ์—…๋กœ๋“œ
243
+ </button>
244
+ </div>
245
+ </div>
246
+ <div id="contyGallery" class="conty-gallery">
247
+ <div class="text-center w-100 py-4 text-muted border rounded-3 bg-light" style="grid-column: 1/-1;">
248
+ <i class="fas fa-cloud-upload-alt fa-2x mb-2 d-block"></i>
249
+ ์—…๋กœ๋“œ๋œ ์ฝ˜ํ‹ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์„ ํ™” ์ œ์ž‘์˜ ๊ธฐ๋ฐ˜์ด ๋  ๊ทธ๋ฆผ ์ฝ˜ํ‹ฐ๋ฅผ ์—…๋กœ๋“œํ•ด์ฃผ์„ธ์š”.
250
+ </div>
251
+ </div>
252
+ <p class="text-muted small mt-2"><i class="fas fa-info-circle me-1"></i> ์—…๋กœ๋“œ๋œ ์ฝ˜ํ‹ฐ ์ค‘ ํ•˜๋‚˜๋ฅผ ์„ ํƒํ•˜๋ฉด ์„ ํ™” ์ƒ์„ฑ ์‹œ ์ฐธ์กฐ ์ด๋ฏธ์ง€๋กœ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.</p>
253
+ </div>
254
+
255
+ <div class="mt-4">
256
+ <button id="generateBtn" class="db-btn db-btn-primary w-100 py-3 fw-bold" onclick="generateLineArt()">
257
+ <i class="fas fa-wand-sparkles me-2"></i> ์„ ํ™” ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์‹œ์ž‘
258
+ </button>
259
+ </div>
260
+ </div>
261
+ </div>
262
+
263
+ <!-- ์˜ค๋ฅธ์ชฝ: ์„ค์ • ๋ฐ ์ฐธ์กฐ -->
264
+ <div class="col-lg-4">
265
+ <div class="clean-card p-4 h-100">
266
+ <h2 class="h5 fw-bold mb-4">์ฐธ์กฐ ์„ค์ •</h2>
267
+
268
+ <div class="mb-4">
269
+ <label class="form-label small fw-bold text-secondary">์ฐธ์กฐ ์›น์†Œ์„ค ํ”„๋กœ์ ํŠธ</label>
270
+ <select id="projectSelect" class="form-select border-0 shadow-sm mb-2" onchange="loadCharacters(this.value)">
271
+ <option value="">-- ํ”„๋กœ์ ํŠธ ์„ ํƒ --</option>
272
+ </select>
273
+ <p class="text-muted small mb-0"><i class="fas fa-info-circle me-1"></i> ํ”„๋กœ์ ํŠธ๋ฅผ ์„ ํƒํ•˜๋ฉด ๋“ฑ๋ก๋œ ๋ฉ”์ธ ์บ๋ฆญํ„ฐ ๋ชฉ๋ก์ด ๋‚˜ํƒ€๋‚ฉ๋‹ˆ๋‹ค.</p>
274
+ </div>
275
+
276
+ <div class="mb-4">
277
+ <div class="d-flex justify-content-between align-items-center mb-2">
278
+ <label class="form-label small fw-bold text-secondary mb-0">์บ๋ฆญํ„ฐ ๋ชฉ๋ก</label>
279
+ <div class="form-check form-switch">
280
+ <input class="form-check-input" type="checkbox" id="includeCharImages" checked>
281
+ <label class="form-check-label small text-muted" for="includeCharImages">์ด๋ฏธ์ง€ ์ •๋ณด ํฌํ•จ</label>
282
+ </div>
283
+ </div>
284
+ <div id="characterList" class="bg-light border rounded p-2" style="max-height: 300px; overflow-y: auto; min-height: 100px;">
285
+ <p class="text-muted small mb-0 p-2 text-center">ํ”„๋กœ์ ํŠธ๋ฅผ ๋จผ์ € ์„ ํƒํ•ด์ฃผ์„ธ์š”.</p>
286
+ </div>
287
+ </div>
288
+ </div>
289
+ </div>
290
+ </div>
291
+
292
+ <!-- ํ•˜๋‹จ ๊ฒฐ๊ณผ ์˜์—ญ -->
293
+ <div id="resultArea" class="d-none mb-5">
294
+ <div class="clean-card p-4">
295
+ <div class="d-flex justify-content-between align-items-center mb-4">
296
+ <h2 class="h5 fw-bold mb-0">์„ ํ™” ์ƒ์„ฑ ๊ฒฐ๊ณผ</h2>
297
+ <a id="downloadLink" href="#" class="db-btn db-btn-outline py-2" download>
298
+ <i class="fas fa-download"></i> ์ด๋ฏธ์ง€ ๋‹ค์šด๋กœ๋“œ
299
+ </a>
300
+ </div>
301
+ <div class="result-image-container" id="imageContainer">
302
+ <div class="text-center py-5">
303
+ <div class="spinner-border text-primary mb-3" role="status"></div>
304
+ <p class="text-muted mb-0" id="loadingText">์ด๋ฏธ์ง€๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ๋งŒ ๊ธฐ๋‹ค๋ ค์ฃผ์„ธ์š”...</p>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ </div>
309
+
310
+ <script>
311
+ let currentFileId = null;
312
+ const defaultAnalysisModel = "{{ default_analysis_model }}";
313
+ let uploadedContyImages = [];
314
+ let selectedContyImageUrl = null;
315
+
316
+ async function loadModels() {
317
+ const genSelect = document.getElementById('modelSelect');
318
+ const analysisSelect = document.getElementById('analysisModelSelect');
319
+ try {
320
+ const response = await fetch('/api/ollama/models?all=true');
321
+ const data = await response.json();
322
+
323
+ if (data.models && data.models.length > 0) {
324
+ const optionsHtml = ['<option value="">๋ชจ๋ธ ์„ ํƒ...</option>'];
325
+ const analysisOptionsHtml = ['<option value="">๋ชจ๋ธ ์„ ํƒ...</option>'];
326
+
327
+ // Gemini ๋ชจ๋ธ ๊ทธ๋ฃน
328
+ const geminiModels = data.models.filter(m => m.type === 'gemini');
329
+ if (geminiModels.length > 0) {
330
+ optionsHtml.push('<optgroup label="โœจ Google Gemini">');
331
+ analysisOptionsHtml.push('<optgroup label="โœจ Google Gemini">');
332
+ geminiModels.forEach(m => {
333
+ const label = m.name.startsWith('gemini:') ? m.name.substring(7) : m.name;
334
+ const genSelected = m.name.includes('gemini-3-pro-image-preview') ? 'selected' : '';
335
+
336
+ // ๋ถ„์„ ๋ชจ๋ธ ๊ธฐ๋ณธ๊ฐ’ ์„ค์ •
337
+ let analysisSelected = '';
338
+ if (defaultAnalysisModel) {
339
+ if (m.name === defaultAnalysisModel || m.name === `gemini:${defaultAnalysisModel}`) {
340
+ analysisSelected = 'selected';
341
+ }
342
+ } else if (m.name.includes('gemini-1.5-flash')) {
343
+ analysisSelected = 'selected';
344
+ }
345
+
346
+ optionsHtml.push(`<option value="${m.name}" ${genSelected}>${label}</option>`);
347
+ analysisOptionsHtml.push(`<option value="${m.name}" ${analysisSelected}>${label}</option>`);
348
+ });
349
+ optionsHtml.push('</optgroup>');
350
+ analysisOptionsHtml.push('</optgroup>');
351
+ }
352
+
353
+ // Ollama ๋ชจ๋ธ ๊ทธ๋ฃน
354
+ const ollamaModels = data.models.filter(m => m.type !== 'gemini');
355
+ if (ollamaModels.length > 0) {
356
+ optionsHtml.push('<optgroup label="๐Ÿค– Ollama (Local)">');
357
+ analysisOptionsHtml.push('<optgroup label="๐Ÿค– Ollama (Local)">');
358
+ ollamaModels.forEach(m => {
359
+ let analysisSelected = (m.name === defaultAnalysisModel) ? 'selected' : '';
360
+ optionsHtml.push(`<option value="${m.name}">${m.name}</option>`);
361
+ analysisOptionsHtml.push(`<option value="${m.name}" ${analysisSelected}>${m.name}</option>`);
362
+ });
363
+ optionsHtml.push('</optgroup>');
364
+ analysisOptionsHtml.push('</optgroup>');
365
+ }
366
+
367
+ genSelect.innerHTML = optionsHtml.join('');
368
+ analysisSelect.innerHTML = analysisOptionsHtml.join('');
369
+ }
370
+ } catch (error) {
371
+ console.error('๋ชจ๋ธ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
372
+ genSelect.innerHTML = '<option value="">๋กœ๋“œ ์‹คํŒจ</option>';
373
+ analysisSelect.innerHTML = '<option value="">๋กœ๋“œ ์‹คํŒจ</option>';
374
+ }
375
+ }
376
+
377
+ async function loadNovelProjects() {
378
+ const select = document.getElementById('projectSelect');
379
+ try {
380
+ const res = await fetch('/api/files?sort=uploaded_at_desc&public_only=true');
381
+ const data = await res.json();
382
+
383
+ if (data.files && data.files.length > 0) {
384
+ data.files.forEach(f => {
385
+ const opt = document.createElement('option');
386
+ opt.value = f.id;
387
+ opt.textContent = f.original_filename;
388
+ select.appendChild(opt);
389
+ });
390
+ }
391
+ } catch (e) {
392
+ console.error("Project load error:", e);
393
+ }
394
+ }
395
+
396
+ async function loadCharacters(fileId) {
397
+ if (!fileId) return;
398
+ currentFileId = fileId;
399
+ const list = document.getElementById('characterList');
400
+ const promptArea = document.getElementById('genPrompt');
401
+ list.innerHTML = '<div class="text-center p-3"><div class="spinner-border spinner-border-sm text-primary"></div></div>';
402
+
403
+ try {
404
+ const res = await fetch('/creation/api/character/extract-main', {
405
+ method: 'POST',
406
+ headers: {'Content-Type': 'application/json'},
407
+ body: JSON.stringify({ file_id: fileId })
408
+ });
409
+ const data = await res.json();
410
+
411
+ // ์ „์—ญ ๋ณ€์ˆ˜์— ํ”„๋กœ์ ํŠธ ๋ฐฐ๊ฒฝ ์ •๋ณด ์ €์žฅ (๋ถ„์„ ์‹œ ์‚ฌ์šฉ)
412
+ window.projectContext = `์„ธ๊ณ„๊ด€: ${data.world_view || '์ •๋ณด ์—†์Œ'}\n์ค„๊ฑฐ๋ฆฌ: ${data.story || '์ •๋ณด ์—†์Œ'}\n๊ธฐํƒ€: ${data.others || '์ •๋ณด ์—†์Œ'}`;
413
+
414
+ // 1. Parent Chunk ์ •๋ณด(์„ธ๊ณ„๊ด€, ๊ธฐํƒ€) ๋ฐ ์บ๋ฆญํ„ฐ ์ •๋ณด๋ฅผ ํ”„๋กฌํ”„ํŠธ์— ์ถ”๊ฐ€
415
+ let contextPrompt = '';
416
+ if (data.world_view || data.others) {
417
+ if (data.world_view) contextPrompt += `### ์„ธ๊ณ„๊ด€ ์„ค๋ช…\n${data.world_view}\n\n`;
418
+ if (data.others) contextPrompt += `### ๊ธฐํƒ€ ์„ค์ •\n${data.others}\n\n`;
419
+ }
420
+
421
+ // 2. ์บ๋ฆญํ„ฐ ๋ชฉ๋ก ์ •๋ณด ์ถ”๊ฐ€
422
+ if (data.characters && data.characters.length > 0) {
423
+ contextPrompt += `### ์บ๋ฆญํ„ฐ ์ •๋ณด ๋ชฉ๋ก\n`;
424
+ data.characters.forEach(c => {
425
+ contextPrompt += `- [${c.name}]: ${c.description || '์ •๋ณด ์—†์Œ'}\n`;
426
+ });
427
+ contextPrompt += `\n`;
428
+ }
429
+
430
+ if (contextPrompt && confirm("์„ ํƒํ•œ ํ”„๋กœ์ ํŠธ์˜ '์„ธ๊ณ„๊ด€/์„ค์ •' ๋ฐ '์บ๋ฆญํ„ฐ ์ •๋ณด'๋ฅผ ์„ ํ™” ํ”„๋กฌํ”„ํŠธ์— ์ถ”๊ฐ€ํ• ๊นŒ์š”?")) {
431
+ promptArea.value = contextPrompt + promptArea.value;
432
+ }
433
+
434
+ if (data.characters && data.characters.length > 0) {
435
+ list.innerHTML = data.characters.map((c, idx) => {
436
+ const mainImg = c.image_path ? `/uploads/${c.image_path}` : '';
437
+ const hasImages = c.images && c.images.length > 0;
438
+
439
+ return `
440
+ <div class="border-bottom character-item cursor-default"
441
+ data-char-index="${idx}">
442
+ ${mainImg ? `<img src="${mainImg}" class="character-item-img">` : `<div class="character-item-img d-flex align-items-center justify-content-center text-muted"><i class="fas fa-user"></i></div>`}
443
+ <div class="character-item-info">
444
+ <div class="character-item-name">${c.name}</div>
445
+ <div class="character-item-desc text-muted small">${c.description || '์„ค๋ช… ์—†์Œ'}</div>
446
+ ${hasImages ? `
447
+ <div class="character-img-gallery mt-1">
448
+ ${c.images.map(img => `<img src="/uploads/${img.image_path}" onclick="event.stopPropagation(); window.open('/uploads/${img.image_path}', '_blank')" title="์ด๋ฏธ์ง€ ํฌ๊ฒŒ ๋ณด๊ธฐ">`).join('')}
449
+ </div>
450
+ ` : ''}
451
+ </div>
452
+ </div>
453
+ `;
454
+ }).join('');
455
+
456
+ window.extractedCharacters = data.characters;
457
+ } else {
458
+ list.innerHTML = '<p class="text-muted small mb-0 p-2 text-center">๋ถ„์„๋œ ์บ๋ฆญํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</p>';
459
+ }
460
+ } catch (e) {
461
+ list.innerHTML = '<p class="text-danger small mb-0 p-2 text-center">๋กœ๋“œ ์‹คํŒจ</p>';
462
+ }
463
+ }
464
+
465
+ async function loadGlobalLineArtPrompt() {
466
+ try {
467
+ const res = await fetch('/creation/api/line-art-challenge/global-prompt');
468
+ const data = await res.json();
469
+ if (data.prompt) {
470
+ document.getElementById('genPrompt').value = data.prompt;
471
+ }
472
+ } catch (e) {
473
+ console.error("Prompt load error:", e);
474
+ }
475
+ }
476
+
477
+ async function uploadContyImages(files) {
478
+ if (!files || files.length === 0) return;
479
+
480
+ for (const file of files) {
481
+ const formData = new FormData();
482
+ formData.append('image', file);
483
+
484
+ try {
485
+ const response = await fetch('/creation/api/upload-image', {
486
+ method: 'POST',
487
+ body: formData
488
+ });
489
+ const data = await response.json();
490
+
491
+ if (data.success) {
492
+ uploadedContyImages.push(data.image_url);
493
+ // ์ฒซ ์ด๋ฏธ์ง€๋ฉด ์ž๋™ ์„ ํƒ
494
+ if (!selectedContyImageUrl) {
495
+ selectedContyImageUrl = data.image_url;
496
+ }
497
+ } else {
498
+ alert(`์—…๋กœ๋“œ ์‹คํŒจ: ${data.error}`);
499
+ }
500
+ } catch (error) {
501
+ console.error('Upload error:', error);
502
+ alert('์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.');
503
+ }
504
+ }
505
+ renderContyGallery();
506
+ }
507
+
508
+ function renderContyGallery() {
509
+ const gallery = document.getElementById('contyGallery');
510
+ if (uploadedContyImages.length === 0) {
511
+ gallery.innerHTML = `
512
+ <div class="text-center w-100 py-4 text-muted border rounded-3 bg-light" style="grid-column: 1/-1;">
513
+ <i class="fas fa-cloud-upload-alt fa-2x mb-2 d-block"></i>
514
+ ์—…๋กœ๋“œ๋œ ์ฝ˜ํ‹ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์„ ํ™” ์ œ์ž‘์˜ ๊ธฐ๋ฐ˜์ด ๋  ๊ทธ๋ฆผ ์ฝ˜ํ‹ฐ๋ฅผ ์—…๋กœ๋“œํ•ด์ฃผ์„ธ์š”.
515
+ </div>
516
+ `;
517
+ return;
518
+ }
519
+
520
+ gallery.innerHTML = uploadedContyImages.map(url => `
521
+ <div class="conty-item ${url === selectedContyImageUrl ? 'selected' : ''}" onclick="selectContyImage('${url}')">
522
+ <img src="${url}" alt="Conty">
523
+ <div class="conty-selection-badge">์„ ํƒ๋จ</div>
524
+ <button class="conty-remove" onclick="event.stopPropagation(); removeContyImage('${url}')">
525
+ <i class="fas fa-times"></i>
526
+ </button>
527
+ </div>
528
+ `).join('');
529
+ }
530
+
531
+ function selectContyImage(url) {
532
+ selectedContyImageUrl = (selectedContyImageUrl === url) ? null : url;
533
+ renderContyGallery();
534
+ }
535
+
536
+ function removeContyImage(url) {
537
+ uploadedContyImages = uploadedContyImages.filter(u => u !== url);
538
+ if (selectedContyImageUrl === url) {
539
+ selectedContyImageUrl = null;
540
+ }
541
+ renderContyGallery();
542
+ }
543
+
544
+ async function generateLineArt() {
545
+ const prompt = document.getElementById('genPrompt').value.trim();
546
+ const model = document.getElementById('modelSelect').value;
547
+ const btn = document.getElementById('generateBtn');
548
+ const resultArea = document.getElementById('resultArea');
549
+ const imageContainer = document.getElementById('imageContainer');
550
+ const includeCharImages = document.getElementById('includeCharImages').checked;
551
+
552
+ if (!prompt) return alert("ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.");
553
+ if (!model) return alert("๋ชจ๋ธ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.");
554
+
555
+ btn.disabled = true;
556
+ btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>์„ ํ™” ์ƒ์„ฑ ์ค‘...';
557
+
558
+ resultArea.classList.remove('d-none');
559
+ imageContainer.innerHTML = `
560
+ <div class="text-center py-5">
561
+ <div class="spinner-border text-primary mb-3" role="status"></div>
562
+ <p class="text-muted mb-0">๊ณ ํ€„๋ฆฌํ‹ฐ ์„ ํ™”๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์ž ์‹œ๋งŒ ๊ธฐ๋‹ค๋ ค์ฃผ์„ธ์š”...</p>
563
+ </div>
564
+ `;
565
+
566
+ resultArea.scrollIntoView({ behavior: 'smooth' });
567
+
568
+ // ์ด๋ฏธ์ง€ URL ๋ฆฌ์ŠคํŠธ ์ˆ˜์ง‘
569
+ const imageUrls = [];
570
+
571
+ // 1. ์„ ํƒ๋œ ๊ทธ๋ฆผ ์ฝ˜ํ‹ฐ ์ด๋ฏธ์ง€๋ฅผ ์ฒซ ๋ฒˆ์งธ๋กœ ์ถ”๊ฐ€ (๊ฐ€์žฅ ์ค‘์š”)
572
+ if (selectedContyImageUrl) {
573
+ imageUrls.push(selectedContyImageUrl);
574
+ }
575
+
576
+ // 2. ์บ๋ฆญํ„ฐ ์ด๋ฏธ์ง€ ์ •๋ณด ํฌํ•จ ์‹œ ์ถ”๊ฐ€
577
+ if (includeCharImages && window.extractedCharacters && window.extractedCharacters.length > 0) {
578
+ window.extractedCharacters.forEach(c => {
579
+ if (c.image_path) {
580
+ // ์ค‘๋ณต ๋ฐฉ์ง€ํ•˜๋ฉฐ ์ถ”๊ฐ€
581
+ const fullPath = `/uploads/${c.image_path}`;
582
+ if (!imageUrls.includes(fullPath)) {
583
+ imageUrls.push(fullPath);
584
+ }
585
+ }
586
+ });
587
+ }
588
+
589
+ try {
590
+ const res = await fetch('/creation/api/generate-image', {
591
+ method: 'POST',
592
+ headers: {'Content-Type': 'application/json'},
593
+ body: JSON.stringify({
594
+ prompt: prompt,
595
+ model_name: model,
596
+ image_urls: imageUrls // ์ˆ˜์ง‘๋œ ๋ชจ๋“  ์ด๋ฏธ์ง€ URL ์ „๋‹ฌ
597
+ })
598
+ });
599
+
600
+ const data = await res.json();
601
+ if (data.success) {
602
+ imageContainer.innerHTML = `<img src="${data.image_url}" class="result-image" alt="Generated Line Art">`;
603
+ document.getElementById('downloadLink').href = data.image_url;
604
+ } else {
605
+ alert("์ƒ์„ฑ ์‹คํŒจ: " + (data.error || "์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜"));
606
+ imageContainer.innerHTML = '<p class="text-danger small p-3">์ด๋ฏธ์ง€ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.</p>';
607
+ }
608
+ } catch (e) {
609
+ console.error("Generation error:", e);
610
+ alert("ํ†ต์‹  ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.");
611
+ imageContainer.innerHTML = '<p class="text-danger small p-3">ํ†ต์‹  ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</p>';
612
+ } finally {
613
+ btn.disabled = false;
614
+ btn.innerHTML = '<i class="fas fa-wand-sparkles me-2"></i> ์„ ํ™” ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์‹œ์ž‘';
615
+ }
616
+ }
617
+
618
+ async function loadGlobalPrompt() {
619
+ try {
620
+ const res = await fetch('/creation/api/line-art-challenge/global-prompt');
621
+ const data = await res.json();
622
+ if (data.prompt) {
623
+ document.getElementById('genPrompt').value = data.prompt;
624
+ }
625
+ } catch (e) {}
626
+ }
627
+
628
+ async function saveGlobalPrompt() {
629
+ const prompt = document.getElementById('genPrompt').value;
630
+ try {
631
+ const res = await fetch('/creation/api/line-art-challenge/global-prompt', {
632
+ method: 'POST',
633
+ headers: {'Content-Type': 'application/json'},
634
+ body: JSON.stringify({ prompt: prompt })
635
+ });
636
+ if (res.ok) alert("์„ ํ™” ๋„์ „ ๊ธฐ๋ณธ ํ”„๋กฌํ”„ํŠธ๋กœ ์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.");
637
+ } catch (e) {
638
+ alert("์ €์žฅ ์‹คํŒจ");
639
+ }
640
+ }
641
+
642
+ document.addEventListener('DOMContentLoaded', () => {
643
+ loadModels();
644
+ loadNovelProjects();
645
+ loadGlobalPrompt();
646
+ });
647
+ </script>
648
+ {% endblock %}