Magenta49 commited on
Commit
e1e1cb9
ยท
1 Parent(s): 8ff9a44

Restore scene filename snippets

Browse files
Files changed (5) hide show
  1. .github/workflows/deploy_to_hf.yml +22 -0
  2. app.py +230 -108
  3. config_style.py +26 -36
  4. logic_image.py +342 -89
  5. utils.py +16 -1
.github/workflows/deploy_to_hf.yml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to Hugging Face Space
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ deploy:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout repository
13
+ uses: actions/checkout@v4
14
+ with:
15
+ fetch-depth: 0
16
+
17
+ - name: Push to Hugging Face Space
18
+ env:
19
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
20
+ run: |
21
+ git remote add space https://PLXR:${HF_TOKEN}@huggingface.co/spaces/PLXR/youtube_auto_image1
22
+ git push --force space main
app.py CHANGED
@@ -2,6 +2,8 @@ import streamlit as st
2
  import os
3
  import io
4
  import concurrent.futures
 
 
5
  from PIL import Image
6
  from google import genai
7
 
@@ -9,6 +11,7 @@ from google import genai
9
  import logic_image
10
  import logic_seo
11
  import logic_tts
 
12
  from config_style import STYLE_DEFINITIONS, THUMBNAIL_STRATEGIES
13
 
14
  # ==========================================
@@ -28,6 +31,10 @@ if 'final_audio' not in st.session_state:
28
  st.session_state['final_audio'] = None
29
  if 'shared_script' not in st.session_state:
30
  st.session_state['shared_script'] = ""
 
 
 
 
31
 
32
  # [CSS] ๋‹คํฌ๋ชจ๋“œ + ์˜ค๋ Œ์ง€ ํฌ์ธํŠธ ๋””์ž์ธ
33
  st.markdown("""
@@ -228,24 +235,48 @@ with tab1:
228
 
229
  st.toast(f"๐Ÿ“œ {len(scenes_text)}๊ฐœ ์žฅ๋ฉด์œผ๋กœ ๋ถ„ํ• ํ•˜์—ฌ ์ƒ์„ฑ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.")
230
 
231
- client = genai.Client(api_key=api_key)
232
  progress_text = "AI ํ™”๊ฐ€๊ฐ€ ๊ทธ๋ฆผ์„ ๊ทธ๋ฆฌ๋Š” ์ค‘์ž…๋‹ˆ๋‹ค..."
233
  my_bar = st.progress(0, text=progress_text)
234
-
235
  temp_results = [None] * len(scenes_text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
 
237
  # ์•ˆ์ •์„ฑ ์œ„ํ•ด max_workers=2
238
  with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
239
  future_to_idx = {
240
- executor.submit(logic_image.process_scene_task, i, {'text': text}, selected_style, custom_style_input, client, text_model_id, image_model_id, aspect_ratio, reference_image): i
241
  for i, text in enumerate(scenes_text)
242
  }
243
  completed = 0
244
  for future in concurrent.futures.as_completed(future_to_idx):
245
- idx, prompt, img = future.result()
246
- temp_results[idx] = (prompt, img, scenes_text[idx])
247
- completed += 1
248
- my_bar.progress(completed / len(scenes_text), text=f"Scene {idx+1} ์™„๋ฃŒ!")
 
 
 
 
 
 
 
 
 
 
 
249
 
250
  st.session_state['scene_results'] = temp_results
251
  my_bar.empty()
@@ -256,9 +287,46 @@ with tab1:
256
  st.divider()
257
  st.subheader("๐ŸŽฌ Scene Results")
258
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  for i, item in enumerate(st.session_state['scene_results']):
260
- if item is None: continue
261
- p_text, img_data, s_text = item
 
 
 
 
262
 
263
  with st.container():
264
  c1, c2 = st.columns([1, 2])
@@ -267,15 +335,18 @@ with tab1:
267
  try:
268
  image = Image.open(io.BytesIO(img_data))
269
  st.image(image, use_container_width=True)
 
270
  st.download_button(
271
  label="โฌ‡๏ธ ๋‹ค์šด๋กœ๋“œ",
272
  data=img_data,
273
- file_name=f"scene_{i+1}.png",
274
  mime="image/png",
275
  key=f"dl_btn_{i}",
276
  use_container_width=True
277
  )
278
- except: st.error("์ด๋ฏธ์ง€ ์˜ค๋ฅ˜")
 
 
279
  else: st.warning("์ด๋ฏธ์ง€ ์—†์Œ")
280
  with c2:
281
  st.markdown(f"**Scene {i+1}**")
@@ -285,9 +356,29 @@ with tab1:
285
  else:
286
  client = genai.Client(api_key=api_key)
287
  with st.spinner("๋‹ค์‹œ ๊ทธ๋ฆฌ๋Š” ์ค‘..."):
288
- _, new_p, new_img = logic_image.process_scene_task(i, {'text': s_text}, selected_style, custom_style_input, client, text_model_id, image_model_id, aspect_ratio, reference_image)
289
- st.session_state['scene_results'][i] = (new_p, new_img, s_text)
290
- st.rerun()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  st.divider()
292
 
293
  # ----------------------------------------------------------------
@@ -310,6 +401,8 @@ with tab2:
310
  seo_result = logic_seo.generate_seo_content(client, text_model_id, seo_script)
311
 
312
  st.session_state["seo_result"] = seo_result
 
 
313
  st.success("๋ถ„์„ ์™„๋ฃŒ!")
314
 
315
  c_seo1, c_seo2 = st.columns(2)
@@ -323,105 +416,132 @@ with tab2:
323
  st.markdown("##### ๐Ÿ“ ์„ค๋ช…๋ž€ (Description)")
324
  st.text_area("์„ค๋ช…๋ž€ ๊ฒฐ๊ณผ", seo_result['description'], height=200)
325
  st.divider()
326
- st.subheader("๐Ÿ–ผ๏ธ ์ธ๋„ค์ผ ์ƒ์„ฑ")
327
-
328
- seo_cached = st.session_state.get("seo_result")
329
 
330
- if not seo_cached:
331
- st.info("SEO ๋ถ„์„์„ ๋จผ์ € ์‹คํ–‰ํ•˜๋ฉด ์ธ๋„ค์ผ ์ƒ์„ฑ ๋ฒ„ํŠผ์ด ๋‚˜ํƒ€๋‚ฉ๋‹ˆ๋‹ค.")
332
- else:
333
- strat_keys = list(THUMBNAIL_STRATEGIES.keys())
334
- sel_strat = st.selectbox("์ธ๋„ค์ผ ์ „๋žต ์„ ํƒ", strat_keys, index=0)
335
 
336
- # ์ธ๋„ค์ผ ํ…์ŠคํŠธ(๋ฉ”์ธ ํƒ€์ดํ‹€) ํ›„๋ณด: SEO ์ถ”์ฒœ ์ œ๋ชฉ 1๊ฐœ๋ฅผ ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ
337
- default_thumb_text = seo_cached["titles"][0] if seo_cached.get("titles") else ""
338
- thumb_text = st.text_input("์ธ๋„ค์ผ ๋ฉ”์ธ ๋ฌธ๊ตฌ(์›ํ•˜๋ฉด ์ˆ˜์ •)", value=default_thumb_text)
339
 
340
- if st.button("๐Ÿง  ์ธ๋„ค์ผ ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ", key="thumb_prompt_btn"):
341
- if not api_key:
342
- st.error("API Key ํ•„์š”")
343
- else:
344
- client = genai.Client(api_key=api_key)
345
-
346
- strategy_block = THUMBNAIL_STRATEGIES[sel_strat]
347
-
348
- prompt = f"""
349
- ๋„ˆ๋Š” ์œ ํŠœ๋ธŒ ์ธ๋„ค์ผ ๊ธฐํš์ž๋‹ค.
350
- ์•„๋ž˜ '๋Œ€๋ณธ'๊ณผ '์ „๋žต'์„ ์ฐธ๊ณ ํ•ด์„œ, ์ด๋ฏธ์ง€ ์ƒ์„ฑ ๋ชจ๋ธ์— ๋„ฃ์„ '์ธ๋„ค์ผ ํ”„๋กฌํ”„ํŠธ'๋ฅผ ๋งŒ๋“ ๋‹ค.
351
-
352
- [๋Œ€๋ณธ]
353
- {seo_script[:8000]}
354
-
355
- [์ „๋žต]
356
- {strategy_block}
357
-
358
- [์ถ”๊ฐ€ ์กฐ๊ฑด]
359
- - ์ธ๋„ค์ผ ์ด๋ฏธ์ง€ ์•ˆ์— ํ…์ŠคํŠธ๋ฅผ ์ง์ ‘ ๊ทธ๋ ค ๋„ฃ์ง€ ๋งˆ๋ผ(๊ธ€์ž ์ƒ์„ฑ ๊ธˆ์ง€).
360
- - ๋Œ€์‹  'ํ…์ŠคํŠธ๋ฅผ ๋„ฃ์„ ์ž๋ฆฌ'๋ฅผ ๊ตฌ๋„๋กœ ํ™•๋ณดํ•ด๋ผ(์ƒ๋‹จ/ํ•˜๋‹จ ์—ฌ๋ฐฑ, ์•ˆ์ „์˜์—ญ).
361
- - ๊ตญ๊ฐ€/๊ตญ๊ธฐ/๋Œ€ํ†ต๋ น/์ฒญ์™€๋Œ€/๊ตญํšŒ์˜์‚ฌ๋‹น ๋“ฑ ํŠน์ • ๊ตญ๊ฐ€ ์ƒ์ง•์ด ์ž๋™์œผ๋กœ ๋‚˜์˜ค์ง€ ์•Š๊ฒŒ,
362
- ์ •์น˜ ์ƒ์ง•๋ฌผ์€ ์ถ”์ƒ์  ์€์œ (์‹ค๋ฃจ์—ฃ, ์กฐ๋ช…, ๊ตฐ์ค‘, ๋ฌด๋Œ€)๋กœ ์ฒ˜๋ฆฌํ•ด๋ผ.
363
- - ํ™”๋ฉด๋น„๋Š” {aspect_ratio}.
364
- - ์ถœ๋ ฅ์€ "ํ”„๋กฌํ”„ํŠธ ํ…์ŠคํŠธ 1๊ฐœ"๋งŒ. JSON/๋งˆํฌ๋‹ค์šด ๊ธˆ์ง€.
365
-
366
- [์‚ฌ์šฉ์ž๊ฐ€ ๋„ฃ์„ ๋ฉ”์ธ ๋ฌธ๊ตฌ(์ฐธ๊ณ ๋งŒ)]
367
- {thumb_text}
368
- """.strip()
369
-
370
- res = client.models.generate_content(model=text_model_id, contents=prompt)
371
- thumb_prompt = (getattr(res, "text", "") or "").strip()
372
- st.session_state["thumb_prompt"] = thumb_prompt
373
- st.success("์ธ๋„ค์ผ ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ ์™„๋ฃŒ!")
374
- st.text_area("์ƒ์„ฑ๋œ ์ธ๋„ค์ผ ํ”„๋กฌํ”„ํŠธ", value=thumb_prompt, height=180)
375
-
376
- # ํ”„๋กฌํ”„ํŠธ๊ฐ€ ์žˆ์„ ๋•Œ๋งŒ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ๋ฒ„ํŠผ ๋…ธ์ถœ
377
- thumb_prompt_cached = st.session_state.get("thumb_prompt", "")
378
- if thumb_prompt_cached:
379
- if st.button("๐ŸŽจ ์ธ๋„ค์ผ ์ด๋ฏธ์ง€ 1์žฅ ์ƒ์„ฑ", key="thumb_img_btn", type="primary"):
380
  if not api_key:
381
  st.error("API Key ํ•„์š”")
 
 
382
  else:
383
- client = genai.Client(api_key=api_key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
384
 
385
- # ์ด๋ฏธ์ง€ ์ƒ์„ฑ (generate_images ์šฐ์„ , ์—†์œผ๋ฉด generate_content fallback)
386
- img_bytes = None
387
- try:
388
- if hasattr(client.models, "generate_images"):
389
- img_res = client.models.generate_images(
390
- model=image_model_id,
391
- prompt=thumb_prompt_cached
392
- )
393
- # logic_image์— ์žˆ๋Š” ์•ˆ์ „ ์ถ”์ถœ ํ•จ์ˆ˜๊ฐ€ ์—†๋‹ค๋ฉด ๊ฐ„๋‹จํžˆ parts์—์„œ ๋ฝ‘๊ธฐ
 
 
 
 
 
394
  try:
395
- cand = img_res.candidates[0]
396
- part = cand.content.parts[0]
397
- img_bytes = part.inline_data.data if part.inline_data else None
398
- except:
399
- img_bytes = None
400
- else:
401
- img_res = client.models.generate_content(
402
- model=image_model_id,
403
- contents=thumb_prompt_cached
404
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
405
  try:
406
- cand = img_res.candidates[0]
407
- part = cand.content.parts[0]
408
- img_bytes = part.inline_data.data if part.inline_data else None
409
- except:
410
- img_bytes = None
411
- except Exception as e:
412
- st.error(f"์ด๋ฏธ์ง€ ์ƒ์„ฑ ์‹คํŒจ: {e}")
413
-
414
- if img_bytes:
415
- st.image(Image.open(io.BytesIO(img_bytes)), use_container_width=True)
416
- st.download_button(
417
- "โฌ‡๏ธ ์ธ๋„ค์ผ ๋‹ค์šด๋กœ๋“œ",
418
- data=img_bytes,
419
- file_name="thumbnail.png",
420
- mime="image/png",
421
- use_container_width=True
422
- )
423
- else:
424
- st.error("์ด๋ฏธ์ง€ ๋ฐ”์ดํŠธ๋ฅผ ์ถ”์ถœํ•˜์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. (๋ชจ๋ธ ์‘๋‹ต ๊ตฌ์กฐ ํ™•์ธ ํ•„์š”)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
 
426
  # ----------------------------------------------------------------
427
  # [TAB 3] AI ์„ฑ์šฐ (TTS)
@@ -459,15 +579,17 @@ with tab3:
459
  if not api_key: st.error("API Key ๏ฟฝ๏ฟฝ์š”")
460
  elif not tts_script: st.warning("๋Œ€๋ณธ ํ•„์š”")
461
  else:
462
- client = genai.Client(api_key=api_key)
463
-
464
  # [ํ•ต์‹ฌ] TTS๋Š” ์Šฌ๋ผ์ด๋” ๊ฐ’(split_criteria)์„ ๋ฌด์‹œํ•˜๊ณ , ๋ฌด์กฐ๊ฑด 500์ž ๋‹จ์œ„๋กœ ๊ณ ์ •
465
  chunks = logic_tts.split_text_smartly(tts_script, limit=500)
466
 
467
  audio_res = [None] * len(chunks)
468
  with st.status("๋…น์Œ ์ง„ํ–‰ ์ค‘...", expanded=True):
469
  with concurrent.futures.ThreadPoolExecutor() as executor:
470
- f_map = {executor.submit(logic_tts.process_tts_task, i, c, client, tts_model_id, voice_opt): i for i, c in enumerate(chunks)}
 
 
 
 
471
  for f in concurrent.futures.as_completed(f_map):
472
  idx, dat = f.result()
473
  if isinstance(dat, bytes):
@@ -478,4 +600,4 @@ with tab3:
478
  if final_wav:
479
  st.success("์˜ค๋””์˜ค ์ƒ์„ฑ ์™„๋ฃŒ!")
480
  st.audio(final_wav, format="audio/wav")
481
- st.download_button("๋‹ค์šด๋กœ๋“œ (WAV)", final_wav, "full_audio.wav", "audio/wav")
 
2
  import os
3
  import io
4
  import concurrent.futures
5
+ import datetime
6
+ import zipfile
7
  from PIL import Image
8
  from google import genai
9
 
 
11
  import logic_image
12
  import logic_seo
13
  import logic_tts
14
+ import utils
15
  from config_style import STYLE_DEFINITIONS, THUMBNAIL_STRATEGIES
16
 
17
  # ==========================================
 
31
  st.session_state['final_audio'] = None
32
  if 'shared_script' not in st.session_state:
33
  st.session_state['shared_script'] = ""
34
+ if 'thumbnail_results' not in st.session_state:
35
+ st.session_state['thumbnail_results'] = []
36
+ if 'thumbnail_text' not in st.session_state:
37
+ st.session_state['thumbnail_text'] = ""
38
 
39
  # [CSS] ๋‹คํฌ๋ชจ๋“œ + ์˜ค๋ Œ์ง€ ํฌ์ธํŠธ ๋””์ž์ธ
40
  st.markdown("""
 
235
 
236
  st.toast(f"๐Ÿ“œ {len(scenes_text)}๊ฐœ ์žฅ๋ฉด์œผ๋กœ ๋ถ„ํ• ํ•˜์—ฌ ์ƒ์„ฑ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.")
237
 
 
238
  progress_text = "AI ํ™”๊ฐ€๊ฐ€ ๊ทธ๋ฆผ์„ ๊ทธ๋ฆฌ๋Š” ์ค‘์ž…๋‹ˆ๋‹ค..."
239
  my_bar = st.progress(0, text=progress_text)
240
+
241
  temp_results = [None] * len(scenes_text)
242
+
243
+ def run_scene_task(task_index, task_text):
244
+ client = genai.Client(api_key=api_key)
245
+ return logic_image.process_scene_task(
246
+ task_index,
247
+ {'text': task_text},
248
+ selected_style,
249
+ custom_style_input,
250
+ client,
251
+ text_model_id,
252
+ image_model_id,
253
+ aspect_ratio,
254
+ reference_image
255
+ )
256
 
257
  # ์•ˆ์ •์„ฑ ์œ„ํ•ด max_workers=2
258
  with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
259
  future_to_idx = {
260
+ executor.submit(run_scene_task, i, text): i
261
  for i, text in enumerate(scenes_text)
262
  }
263
  completed = 0
264
  for future in concurrent.futures.as_completed(future_to_idx):
265
+ idx = future_to_idx[future]
266
+ try:
267
+ _, prompt, img = future.result()
268
+ temp_results[idx] = {
269
+ "prompt": prompt,
270
+ "img_bytes": img,
271
+ "script_text": scenes_text[idx],
272
+ }
273
+ completed += 1
274
+ my_bar.progress(completed / len(scenes_text), text=f"Scene {idx+1} ์™„๋ฃŒ!")
275
+ except Exception as exc:
276
+ completed += 1
277
+ my_bar.progress(completed / len(scenes_text), text=f"Scene {idx+1} ์‹คํŒจ")
278
+ st.error(f"Scene {idx+1} ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.")
279
+ st.exception(exc)
280
 
281
  st.session_state['scene_results'] = temp_results
282
  my_bar.empty()
 
287
  st.divider()
288
  st.subheader("๐ŸŽฌ Scene Results")
289
 
290
+ def _normalize_scene_item(item):
291
+ if isinstance(item, dict):
292
+ return item
293
+ if isinstance(item, (list, tuple)) and len(item) == 3:
294
+ prompt_text, img_bytes, script_text = item
295
+ return {
296
+ "prompt": prompt_text,
297
+ "img_bytes": img_bytes,
298
+ "script_text": script_text,
299
+ }
300
+ return {}
301
+
302
+ scene_zip_buffer = io.BytesIO()
303
+ with zipfile.ZipFile(scene_zip_buffer, "w") as zip_file:
304
+ for i, item in enumerate(st.session_state['scene_results']):
305
+ item = _normalize_scene_item(item)
306
+ if not item:
307
+ continue
308
+ img_data = item.get("img_bytes")
309
+ if img_data:
310
+ snippet = utils.make_scene_snippet(item.get("script_text", ""))
311
+ zip_file.writestr(f"scene_{i+1:02d}_{snippet}.png", img_data)
312
+ scene_zip_buffer.seek(0)
313
+ if scene_zip_buffer.getbuffer().nbytes > 0:
314
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M")
315
+ st.download_button(
316
+ label="โฌ‡๏ธ ๋ชจ๋“  ์ด๋ฏธ์ง€ ํ•œ๋ฒˆ์— ๋‹ค์šด๋กœ๋“œ",
317
+ data=scene_zip_buffer.getvalue(),
318
+ file_name=f"scenes_{timestamp}.zip",
319
+ mime="application/zip",
320
+ use_container_width=True
321
+ )
322
+
323
  for i, item in enumerate(st.session_state['scene_results']):
324
+ item = _normalize_scene_item(item)
325
+ if not item:
326
+ continue
327
+ p_text = item.get("prompt", "")
328
+ img_data = item.get("img_bytes")
329
+ s_text = item.get("script_text", "")
330
 
331
  with st.container():
332
  c1, c2 = st.columns([1, 2])
 
335
  try:
336
  image = Image.open(io.BytesIO(img_data))
337
  st.image(image, use_container_width=True)
338
+ snippet = utils.make_scene_snippet(s_text)
339
  st.download_button(
340
  label="โฌ‡๏ธ ๋‹ค์šด๋กœ๋“œ",
341
  data=img_data,
342
+ file_name=f"scene_{i+1:02d}_{snippet}.png",
343
  mime="image/png",
344
  key=f"dl_btn_{i}",
345
  use_container_width=True
346
  )
347
+ except Exception as exc:
348
+ st.error("์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.")
349
+ st.exception(exc)
350
  else: st.warning("์ด๋ฏธ์ง€ ์—†์Œ")
351
  with c2:
352
  st.markdown(f"**Scene {i+1}**")
 
356
  else:
357
  client = genai.Client(api_key=api_key)
358
  with st.spinner("๋‹ค์‹œ ๊ทธ๋ฆฌ๋Š” ์ค‘..."):
359
+ try:
360
+ _, new_p, new_img = logic_image.process_scene_task(
361
+ i,
362
+ {'text': s_text},
363
+ selected_style,
364
+ custom_style_input,
365
+ client,
366
+ text_model_id,
367
+ image_model_id,
368
+ aspect_ratio,
369
+ reference_image
370
+ )
371
+ st.session_state['scene_results'][i] = {
372
+ "prompt": new_p,
373
+ "img_bytes": new_img,
374
+ "script_text": s_text,
375
+ }
376
+ st.rerun()
377
+ except Exception as exc:
378
+ st.error("์ด๋ฏธ์ง€ ์žฌ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.")
379
+ st.exception(exc)
380
+ with st.expander("ํ”„๋กฌํ”„ํŠธ ๋ณด๊ธฐ"):
381
+ st.write(p_text)
382
  st.divider()
383
 
384
  # ----------------------------------------------------------------
 
401
  seo_result = logic_seo.generate_seo_content(client, text_model_id, seo_script)
402
 
403
  st.session_state["seo_result"] = seo_result
404
+ st.session_state["thumbnail_results"] = []
405
+ st.session_state["thumbnail_text"] = seo_result["titles"][0] if seo_result.get("titles") else ""
406
  st.success("๋ถ„์„ ์™„๋ฃŒ!")
407
 
408
  c_seo1, c_seo2 = st.columns(2)
 
416
  st.markdown("##### ๐Ÿ“ ์„ค๋ช…๋ž€ (Description)")
417
  st.text_area("์„ค๋ช…๋ž€ ๊ฒฐ๊ณผ", seo_result['description'], height=200)
418
  st.divider()
 
 
 
419
 
420
+ st.subheader("๐Ÿ–ผ๏ธ ์ธ๋„ค์ผ ์ƒ์„ฑ")
 
 
 
 
421
 
422
+ seo_cached = st.session_state.get("seo_result")
 
 
423
 
424
+ if not seo_cached:
425
+ st.info("SEO ๋ถ„์„์„ ๋จผ์ € ์‹คํ–‰ํ•˜๋ฉด ์ธ๋„ค์ผ ์ƒ์„ฑ ๋ฒ„ํŠผ์ด ๋‚˜ํƒ€๋‚ฉ๋‹ˆ๋‹ค.")
426
+ else:
427
+ default_thumb_text = seo_cached["titles"][0] if seo_cached.get("titles") else ""
428
+ if not st.session_state["thumbnail_text"]:
429
+ st.session_state["thumbnail_text"] = default_thumb_text
430
+ thumb_text = st.text_input(
431
+ "์ธ๋„ค์ผ ๋ฉ”์ธ ๋ฌธ๊ตฌ(์ฐธ๊ณ ์šฉ)",
432
+ value=st.session_state["thumbnail_text"]
433
+ )
434
+ st.session_state["thumbnail_text"] = thumb_text
435
+ thumbnail_aspect_ratio = "16:9"
436
+
437
+ if st.button("๐ŸŽจ ์ธ๋„ค์ผ 3์ข… ๋™์‹œ ์ƒ์„ฑ", key="thumb_img_btn", type="primary"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  if not api_key:
439
  st.error("API Key ํ•„์š”")
440
+ elif not seo_script:
441
+ st.warning("๋Œ€๋ณธ ํ•„์š”")
442
  else:
443
+ progress_text = "์ธ๋„ค์ผ 3์ข… ์ƒ์„ฑ ์ค‘..."
444
+ my_bar = st.progress(0, text=progress_text)
445
+
446
+ strategy_items = list(THUMBNAIL_STRATEGIES.items())
447
+ temp_results = [None] * len(strategy_items)
448
+
449
+ def run_thumbnail_task(task_index, strategy_key, strategy_text):
450
+ client = genai.Client(api_key=api_key)
451
+ return logic_image.process_thumbnail_task(
452
+ task_index,
453
+ strategy_key,
454
+ strategy_text,
455
+ seo_script,
456
+ thumb_text,
457
+ client,
458
+ text_model_id,
459
+ image_model_id,
460
+ thumbnail_aspect_ratio
461
+ )
462
 
463
+ with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
464
+ future_to_idx = {
465
+ executor.submit(
466
+ run_thumbnail_task,
467
+ i,
468
+ key,
469
+ strategy_text
470
+ ): i
471
+ for i, (key, strategy_text) in enumerate(strategy_items)
472
+ }
473
+ completed = 0
474
+ for future in concurrent.futures.as_completed(future_to_idx):
475
+ idx = future_to_idx[future]
476
+ strategy_key = strategy_items[idx][0]
477
  try:
478
+ _, prompt, img = future.result()
479
+ temp_results[idx] = (strategy_key, prompt, img)
480
+ completed += 1
481
+ my_bar.progress(completed / len(strategy_items), text=f"{strategy_key} ์™„๋ฃŒ!")
482
+ except Exception as exc:
483
+ completed += 1
484
+ my_bar.progress(completed / len(strategy_items), text=f"{strategy_key} ์‹คํŒจ")
485
+ st.error(f"{strategy_key} ์ธ๋„ค์ผ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.")
486
+ st.exception(exc)
487
+
488
+ st.session_state['thumbnail_results'] = temp_results
489
+ my_bar.empty()
490
+ st.rerun()
491
+
492
+ if st.session_state.get('thumbnail_results'):
493
+ cols = st.columns(3)
494
+ for idx, (col, item) in enumerate(zip(cols, st.session_state['thumbnail_results'])):
495
+ if item is None:
496
+ continue
497
+ strategy_key, prompt_text, img_data = item
498
+ with col:
499
+ st.markdown(f"**{strategy_key}**")
500
+ if img_data:
501
  try:
502
+ image = Image.open(io.BytesIO(img_data))
503
+ st.image(image, use_container_width=True)
504
+ st.download_button(
505
+ label="โฌ‡๏ธ ๋‹ค์šด๋กœ๋“œ",
506
+ data=img_data,
507
+ file_name=f"thumbnail_{strategy_key.split('.')[0].lower()}.png",
508
+ mime="image/png",
509
+ key=f"thumb_dl_{strategy_key}",
510
+ use_container_width=True
511
+ )
512
+ except Exception as exc:
513
+ st.error("์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.")
514
+ st.exception(exc)
515
+ else:
516
+ st.warning("์ด๋ฏธ์ง€ ์—†์Œ")
517
+ if st.button("๐Ÿ”„ ์žฌ์ƒ์„ฑ", key=f"thumb_regen_{strategy_key}"):
518
+ if not api_key:
519
+ st.error("API Key ํ•„์š”")
520
+ elif not seo_script:
521
+ st.warning("๋Œ€๋ณธ ํ•„์š”")
522
+ else:
523
+ client = genai.Client(api_key=api_key)
524
+ strategy_text = THUMBNAIL_STRATEGIES[strategy_key]
525
+ with st.spinner("์ธ๋„ค์ผ ์žฌ์ƒ์„ฑ ์ค‘..."):
526
+ try:
527
+ _, new_prompt, new_img = logic_image.process_thumbnail_task(
528
+ idx,
529
+ strategy_key,
530
+ strategy_text,
531
+ seo_script,
532
+ thumb_text,
533
+ client,
534
+ text_model_id,
535
+ image_model_id,
536
+ thumbnail_aspect_ratio
537
+ )
538
+ st.session_state['thumbnail_results'][idx] = (strategy_key, new_prompt, new_img)
539
+ st.rerun()
540
+ except Exception as exc:
541
+ st.error("์ธ๋„ค์ผ ์žฌ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.")
542
+ st.exception(exc)
543
+ with st.expander("ํ”„๋กฌํ”„ํŠธ ๋ณด๊ธฐ"):
544
+ st.write(prompt_text)
545
 
546
  # ----------------------------------------------------------------
547
  # [TAB 3] AI ์„ฑ์šฐ (TTS)
 
579
  if not api_key: st.error("API Key ๏ฟฝ๏ฟฝ์š”")
580
  elif not tts_script: st.warning("๋Œ€๋ณธ ํ•„์š”")
581
  else:
 
 
582
  # [ํ•ต์‹ฌ] TTS๋Š” ์Šฌ๋ผ์ด๋” ๊ฐ’(split_criteria)์„ ๋ฌด์‹œํ•˜๊ณ , ๋ฌด์กฐ๊ฑด 500์ž ๋‹จ์œ„๋กœ ๊ณ ์ •
583
  chunks = logic_tts.split_text_smartly(tts_script, limit=500)
584
 
585
  audio_res = [None] * len(chunks)
586
  with st.status("๋…น์Œ ์ง„ํ–‰ ์ค‘...", expanded=True):
587
  with concurrent.futures.ThreadPoolExecutor() as executor:
588
+ def run_tts_task(task_index, chunk):
589
+ client = genai.Client(api_key=api_key)
590
+ return logic_tts.process_tts_task(task_index, chunk, client, tts_model_id, voice_opt)
591
+
592
+ f_map = {executor.submit(run_tts_task, i, c): i for i, c in enumerate(chunks)}
593
  for f in concurrent.futures.as_completed(f_map):
594
  idx, dat = f.result()
595
  if isinstance(dat, bytes):
 
600
  if final_wav:
601
  st.success("์˜ค๋””์˜ค ์ƒ์„ฑ ์™„๋ฃŒ!")
602
  st.audio(final_wav, format="audio/wav")
603
+ st.download_button("๋‹ค์šด๋กœ๋“œ (WAV)", final_wav, "full_audio.wav", "audio/wav")
config_style.py CHANGED
@@ -25,46 +25,36 @@ STYLE_DEFINITIONS = {
25
  }
26
 
27
 
28
- # 2. ์ธ๋„ค์ผ ์ƒ์„ฑ ์ „๋žต ์ •์˜ (์ถ”ํ›„ ์ธ๋„ค์ผ ์ƒ์„ฑ ๊ธฐ๋Šฅ ํ™•์žฅ ์‹œ ์‚ฌ์šฉ)
29
  THUMBNAIL_STRATEGIES = {
30
  "A. ๊ทน์  ๋Œ€๋น„ (Split Screen)": """
31
- [Strategy Goal]
32
- Create a thumbnail with a 50:50 vertical split.
33
- Left Side: Negative/Past/Weak (Dark tone).
34
- Right Side: Positive/Future/Strong (Bright tone).
35
-
36
- [CRITICAL RULES]
37
- 1. **Text Language:** THE TEXT OVERLAY MUST BE IN KOREAN. Extract key phrases from the script in KOREAN. Do not translate them.
38
- 2. **Composition:** Split screen vertically. Distinct visual separation.
39
- 3. **Style:** Realistic, high-definition, YouTube documentary style, dramatic lighting.
40
-
41
- [Prompt Structure to Generate]
42
- "Split screen. Left: [Description of A], dark lighting, text: '[ํ•œ๊ตญ์–ด ํ…์ŠคํŠธ]'. Right: [Description of B], bright lighting, text: '[ํ•œ๊ตญ์–ด ํ…์ŠคํŠธ]'. Bottom: Big bold text '[ํ•œ๊ตญ์–ด ๋ฉ”์ธ ํƒ€์ดํ‹€]'. Realistic style, 8k."
43
  """,
44
 
45
- "B. ํ•˜์ด๋ธŒ๋ฆฌ๋“œ (Real + 2D)": """
46
- [Strategy Goal]
47
- Composite image. Realistic photo background + 2D vector stickman foreground.
48
-
49
- [CRITICAL RULES]
50
- 1. **Text Language:** THE TEXT OVERLAY MUST BE IN KOREAN.
51
- 2. **Background:** Realistic photo of key subject/location. Documentary news style.
52
- 3. **Foreground:** Simple 2D stickman character reacting/pointing.
53
-
54
- [Prompt Structure to Generate]
55
- "Composite image. Background: Realistic photo of [Key Subject], news style. Foreground: 2D stickman [Action/Reaction]. Text at bottom: '[ํ•œ๊ตญ์–ด ๋ฉ”์ธ ํƒ€์ดํ‹€]', bold font, yellow/red color."
56
  """,
57
 
58
- "C. ๋‚˜๋…ธ๋ฐ”๋‚˜๋‚˜ (2D Stickman)": """
59
- [Strategy Goal]
60
- Flat 2D vector art, simple stickman character.
61
-
62
- [CRITICAL RULES]
63
- 1. **Text Language:** THE TEXT OVERLAY MUST BE IN KOREAN.
64
- 2. **Style:** Flat 2D vector art, clean lines. NO speech bubbles.
65
- 3. **Logic:** If comparing -> Split screen. If explaining -> Single wide shot.
66
-
67
- [Prompt Structure to Generate]
68
- "Flat 2D vector art. Stickman character in [Pose]. Background: [Elements]. Text at bottom: '[ํ•œ๊ตญ์–ด ๋ฉ”์ธ ํƒ€์ดํ‹€]'. No speech bubbles."
69
  """
70
- }
 
25
  }
26
 
27
 
28
+ # 2. ์ธ๋„ค์ผ ์ƒ์„ฑ ์ „๋žต ์ •์˜ (A/B/C ๊ณ ์ •)
29
  THUMBNAIL_STRATEGIES = {
30
  "A. ๊ทน์  ๋Œ€๋น„ (Split Screen)": """
31
+ - ํ™”๋ฉด์„ ์ •ํ™•ํžˆ 50:50 ์ˆ˜์ง ๋ถ„ํ• .
32
+ - Left: ๋ถ€์ •์ /๊ณผ๊ฑฐ/์•ฝ์ž ๋ถ„์œ„๊ธฐ (์–ด๋‘ก๊ณ  ํ˜ผ๋ž€).
33
+ - Right: ๊ธ์ •์ /๋ฏธ๋ž˜/๊ฐ•์ž ๋ถ„์œ„๊ธฐ (๋ฐ๊ณ  ์งˆ์„œ).
34
+ - ํ…์ŠคํŠธ ์˜ค๋ฒ„๋ ˆ์ด ํ•„์ˆ˜:
35
+ * ์ขŒ์ธก ์ƒ๋‹จ: ์งง์€ ๋ถ€์ • ๋ฌธ๊ตฌ
36
+ * ์šฐ์ธก ์ƒ๋‹จ: ์งง์€ ๊ธ์ • ๋ฌธ๊ตฌ
37
+ * ํ•˜๋‹จ ์ค‘์•™: ๋ฉ”์ธ ํƒ€์ดํ‹€(thumb_text)
38
+ - Style: Realistic documentary, dramatic lighting.
 
 
 
 
39
  """,
40
 
41
+ "B. ์‹ค์‚ฌ + 2D ์Šคํ‹ฑ๋งจ ํ•˜์ด๋ธŒ๋ฆฌ๋“œ": """
42
+ - Background: ํ˜„์‹ค ๋‹คํ ๋‰ด์Šค ์‚ฌ์ง„ ์Šคํƒ€์ผ.
43
+ - Foreground: 2D ์Šคํ‹ฑ๋งจ ์บ๋ฆญํ„ฐ๊ฐ€ ์ถฉ๊ฒฉ/๊ฐ•์กฐ ๋ฆฌ์•ก์…˜.
44
+ - ํ…์ŠคํŠธ ์˜ค๋ฒ„๋ ˆ์ด ํ•„์ˆ˜:
45
+ * ์ƒ๋‹จ: ์†๋ณด/๊ธด๊ธ‰/๋‹จ๋… ๊ฐ™์€ ์„œ๋ธŒ ๋ฌธ๊ตฌ
46
+ * ํ•˜๋‹จ: ๋ฉ”์ธ ํƒ€์ดํ‹€(thumb_text)
47
+ - Style: News composite, high CTR.
 
 
 
 
48
  """,
49
 
50
+ "C. 2D Stickman ์ „์šฉ": """
51
+ - Flat vector, clean stickman.
52
+ - ๋งํ’์„  ์ ˆ๋Œ€ ๊ธˆ์ง€.
53
+ - ๋Œ€๋ณธ ๊ตฌ์กฐ์— ๋”ฐ๋ผ:
54
+ * ๋น„๊ต๋ฉด Split Screen
55
+ * ์Šคํ† ๋ฆฌ๋ฉด Single Scene
56
+ - ํ…์ŠคํŠธ ์˜ค๋ฒ„๋ ˆ์ด ํ•„์ˆ˜:
57
+ * ํ•˜๋‹จ: ๋ฉ”์ธ ํƒ€์ดํ‹€(thumb_text)
58
+ * ์ƒ๋‹จ: ์„ ํƒ์  ์„œ๋ธŒ ๋ฌธ๊ตฌ
 
 
59
  """
60
+ }
logic_image.py CHANGED
@@ -1,6 +1,28 @@
1
  # logic_image.py
 
 
2
  import re
3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  def _get_style_prompt(selected_style, custom_style_input, style_definitions):
5
  if selected_style == "์ง์ ‘ ์ž…๋ ฅ":
6
  return (custom_style_input or "").strip()
@@ -15,29 +37,113 @@ def _safe_extract_image_bytes(response):
15
  (๋ชจ๋ธ/๋ฒ„์ „๋ณ„๋กœ ์‘๋‹ต ๊ตฌ์กฐ๊ฐ€ ๋‹ฌ๋ผ์„œ ๋ฐฉ์–ด์ ์œผ๋กœ ์ฒ˜๋ฆฌ)
16
  """
17
  # candidates -> content -> parts -> inline_data.data ํŒจํ„ด
18
- try:
19
- cands = getattr(response, "candidates", None) or []
20
- if not cands:
21
- return None
22
- content = getattr(cands[0], "content", None)
23
- parts = getattr(content, "parts", None) or []
24
- for p in parts:
25
- inline = getattr(p, "inline_data", None)
26
- if inline and getattr(inline, "data", None):
27
- return inline.data
28
- except:
29
- pass
 
 
 
30
 
31
  # ํ˜น์‹œ response.image ๊ฐ™์€ ํ˜•ํƒœ๋กœ ์˜ค๋Š” ๊ฒฝ์šฐ
32
  for key in ["image", "images", "data"]:
33
- try:
34
- v = getattr(response, key, None)
35
- if isinstance(v, (bytes, bytearray)):
36
- return bytes(v)
37
- except:
38
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  def process_scene_task(
43
  index,
@@ -84,64 +190,117 @@ def process_scene_task(
84
  3) ํ™”๋ฉด ๋ถ„ํ• (Split Screen) ๊ธˆ์ง€. ํ•œ ์žฅ๋ฉด์œผ๋กœ๋งŒ ๊ตฌ์„ฑ.
85
  4) ์ด๋ฏธ์ง€์— ๊ธ€์ž/์ž๋ง‰ ๋„ฃ์ง€ ๋งˆ๋ผ. (ํ…์ŠคํŠธ ์˜ค๋ฒ„๋ ˆ์ด ๊ธˆ์ง€)
86
  5) ์ตœ์ข… ์ถœ๋ ฅ์€ "ํ”„๋กฌํ”„ํŠธ ํ•œ ๋ฉ์–ด๋ฆฌ ํ…์ŠคํŠธ"๋งŒ. JSON/๋งˆํฌ๋‹ค์šด/๋ฒˆํ˜ธ/์ œ๋ชฉ ๊ธˆ์ง€.
 
 
87
 
88
  ์ด์ œ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ถœ๋ ฅํ•ด.
89
  """.strip()
90
 
91
 
92
- brain_res = client.models.generate_content(
93
- model=text_model_id,
94
- contents=brain_prompt
95
- )
 
 
 
 
96
 
97
  # brain_res.text๊ฐ€ ์—†์„ ์ˆ˜๋„ ์žˆ์–ด ๋ฐฉ์–ด
98
  final_prompt = getattr(brain_res, "text", None) or scene_text
99
  final_prompt = re.sub(r"\s+", " ", final_prompt).strip()
100
 
101
- # 2) Painter: ํ”„๋กฌํ”„ํŠธ -> ์ด๋ฏธ์ง€ ์ƒ์„ฑ
102
- # google-genai ๋ฒ„์ „์— ๋”ฐ๋ผ generate_images / generate_content ๋‘˜ ๋‹ค ๋Œ€์‘
103
- img_bytes = None
104
-
105
- # (A) generate_images๊ฐ€ ์žˆ์œผ๋ฉด ์šฐ์„  ์‚ฌ์šฉ
106
- if hasattr(client.models, "generate_images"):
107
- try:
108
- img_res = client.models.generate_images(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  model=image_model_id,
110
- prompt=final_prompt
 
 
 
 
111
  )
112
- # ์‘๋‹ต ๊ตฌ์กฐ๊ฐ€ ๋ฒ„์ „๋งˆ๋‹ค ๋‹ฌ๋ผ์„œ ์ตœ๋Œ€ํ•œ ๋ฐฉ์–ด์ ์œผ๋กœ ์ถ”์ถœ
113
- img_bytes = _safe_extract_image_bytes(img_res)
114
- except:
115
- img_bytes = None
116
 
117
- # (B) ์—†์œผ๋ฉด generate_content๋กœ ์‹œ๋„
118
- if img_bytes is None:
119
- try:
120
- # reference_image๊ฐ€ ์žˆ์„ ๊ฒฝ์šฐ, ๊ฐ€๋Šฅํ•œ ํ˜•ํƒœ๋กœ ๊ฐ™์ด ์ „๋‹ฌ ์‹œ๋„
121
- contents = final_prompt
122
- if reference_image is not None:
123
- # genai.types.Part ๊ฐ€ ์žˆ์œผ๋ฉด ๊ทธ๊ฑธ ์‚ฌ์šฉ
124
- try:
125
- from google.genai import types
126
- import io
127
- buf = io.BytesIO()
128
- reference_image.save(buf, format="PNG")
129
- ref_part = types.Part.from_bytes(data=buf.getvalue(), mime_type="image/png")
130
- contents = [final_prompt, ref_part]
131
- except:
132
- # types๊ฐ€ ์—†์œผ๋ฉด ๊ทธ๋ƒฅ ํ…์ŠคํŠธ๋งŒ
133
- contents = final_prompt
134
-
135
- img_res = client.models.generate_content(
136
  model=image_model_id,
137
- contents=contents
138
  )
139
- img_bytes = _safe_extract_image_bytes(img_res)
140
- except:
141
- img_bytes = None
 
 
 
 
 
142
 
143
- return index, final_prompt, img_bytes
144
- def process_thumbnail_task(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  index,
146
  strategy_key,
147
  strategy_text,
@@ -151,7 +310,7 @@ def process_scene_task(
151
  text_model_id,
152
  image_model_id,
153
  aspect_ratio,
154
- reference_image=None
155
  ):
156
  """
157
  return (idx, final_prompt, image_bytes)
@@ -176,43 +335,137 @@ def process_scene_task(
176
 
177
  [ํ•„์ˆ˜ ์กฐ๊ฑด]
178
  - ์ถœ๋ ฅ์€ ๋ฌด์กฐ๊ฑด ํ•œ๊ตญ์–ด๋งŒ. ์˜์–ด/๋กœ๋งˆ์ž ๊ธˆ์ง€.
179
- - ์ด๋ฏธ์ง€ ์•ˆ์— ๊ธ€์ž/์ž๋ง‰/๋ฐฐ๋„ˆ/๋กœ๊ณ  ์ ˆ๋Œ€ ์ƒ์„ฑ ๊ธˆ์ง€.
180
- - ๋Œ€์‹  ํ…์ŠคํŠธ๋ฅผ ๋‚˜์ค‘์— ๋„ฃ์„ ์ˆ˜ ์žˆ๊ฒŒ ์ƒ๋‹จ ๋˜๋Š” ํ•˜๋‹จ์— ๋„“์€ ์—ฌ๋ฐฑ(์•ˆ์ „์˜์—ญ)์„ ํ™•๋ณด.
181
  - ํŠน์ • ๊ตญ๊ฐ€ ์ƒ์ง• ์ž๋™์ƒ์„ฑ ๊ธˆ์ง€: ํƒœ๊ทน๊ธฐ, ์ฒญ์™€๋Œ€, ๊ตญํšŒ์˜์‚ฌ๋‹น, ๋Œ€ํ†ต๋ น, ํ›ˆ์žฅ/ํœ˜์žฅ, ์„ ๊ฑฐ ํฌ์Šคํ„ฐ, ๊ตญ๊ธฐ๋ฅ˜ ์ „๋ถ€ ๊ธˆ์ง€.
182
  - ์ •์น˜์ธ์„ ์‹ค์กด ์ธ๋ฌผ์ฒ˜๋Ÿผ ํŠน์ •ํ•˜์ง€ ๋ง๊ณ , ์ต๋ช… ์บ๋ฆญํ„ฐ/์‹ค๋ฃจ์—ฃ/์ƒ์ง•์  ์—ฐ์ถœ๋กœ ์ฒ˜๋ฆฌ.
183
  - ํ™”๋ฉด ๋ถ„ํ• ์€ ์ „๋žต์— ํฌํ•จ๋œ ๊ฒฝ์šฐ์—๋งŒ ํ—ˆ์šฉ.
184
- - ํ™”๋ฉด๋น„: {aspect_ratio}
185
  - ์ตœ์ข… ์ถœ๋ ฅ์€ ํ”„๋กฌํ”„ํŠธ ํ…์ŠคํŠธ 1๊ฐœ๋งŒ. (JSON/๋งˆํฌ๋‹ค์šด/๋ฒˆํ˜ธ ๊ธˆ์ง€)
186
  """.strip()
187
 
188
- brain_res = client.models.generate_content(model=text_model_id, contents=brain_prompt)
189
- final_prompt = (getattr(brain_res, "text", "") or "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
- # 2) Painter: Tab1๊ณผ ๋™์ผํ•œ ๋ฐฉ์‹์œผ๋กœ ์ด๋ฏธ์ง€ ์ƒ์„ฑ
192
- img_bytes = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
194
- # (A) generate_images ์šฐ์„ 
195
- if hasattr(client.models, "generate_images"):
196
- try:
197
- img_res = client.models.generate_images(
198
- model=image_model_id,
199
- prompt=final_prompt
200
- )
201
- img_bytes = _safe_extract_image_bytes(img_res)
202
- except:
203
- img_bytes = None
204
 
205
- # (B) fallback: generate_content
206
- if img_bytes is None:
207
- try:
208
- contents = final_prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  img_res = client.models.generate_content(
210
  model=image_model_id,
211
- contents=contents
 
 
 
 
212
  )
213
- img_bytes = _safe_extract_image_bytes(img_res)
214
- except:
215
- img_bytes = None
216
-
217
- return index, final_prompt, img_bytes
 
 
 
 
 
 
 
 
 
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # logic_image.py
2
+ import json
3
+ import logging
4
  import re
5
 
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ class ImageExtractionError(RuntimeError):
10
+ """Raised when image bytes cannot be extracted from a model response."""
11
+
12
+
13
+ def _summarize_response_structure(response):
14
+ if response is None:
15
+ return "response=None"
16
+ summary = {
17
+ "type": type(response).__name__,
18
+ "has_candidates": hasattr(response, "candidates"),
19
+ "has_content": hasattr(response, "content"),
20
+ "has_image": hasattr(response, "image"),
21
+ "has_images": hasattr(response, "images"),
22
+ "has_data": hasattr(response, "data"),
23
+ }
24
+ return ", ".join(f"{k}={v}" for k, v in summary.items())
25
+
26
  def _get_style_prompt(selected_style, custom_style_input, style_definitions):
27
  if selected_style == "์ง์ ‘ ์ž…๋ ฅ":
28
  return (custom_style_input or "").strip()
 
37
  (๋ชจ๋ธ/๋ฒ„์ „๋ณ„๋กœ ์‘๋‹ต ๊ตฌ์กฐ๊ฐ€ ๋‹ฌ๋ผ์„œ ๋ฐฉ์–ด์ ์œผ๋กœ ์ฒ˜๋ฆฌ)
38
  """
39
  # candidates -> content -> parts -> inline_data.data ํŒจํ„ด
40
+ cands = getattr(response, "candidates", None) or []
41
+ if cands:
42
+ for cand in cands:
43
+ content = getattr(cand, "content", None)
44
+ parts = getattr(content, "parts", None) or []
45
+ for p in parts:
46
+ inline = getattr(p, "inline_data", None)
47
+ data = getattr(inline, "data", None) if inline else None
48
+ if data:
49
+ return data
50
+ text_parts = [getattr(p, "text", None) for p in parts if getattr(p, "text", None)]
51
+ if text_parts:
52
+ snippet = re.sub(r"\s+", " ", " ".join(text_parts)).strip()[:200]
53
+ raise ImageExtractionError(f"ํ…์ŠคํŠธ๋งŒ ๋ฐ˜ํ™˜๋˜์–ด ์ด๋ฏธ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ํ…์ŠคํŠธ ์ผ๋ถ€: {snippet}")
54
+ raise ImageExtractionError("์ด๋ฏธ์ง€ ํŒŒํŠธ๊ฐ€ ์—†์–ด bytes๋ฅผ ์ถ”์ถœํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
55
 
56
  # ํ˜น์‹œ response.image ๊ฐ™์€ ํ˜•ํƒœ๋กœ ์˜ค๋Š” ๊ฒฝ์šฐ
57
  for key in ["image", "images", "data"]:
58
+ v = getattr(response, key, None)
59
+ if isinstance(v, (bytes, bytearray)):
60
+ return bytes(v)
61
+
62
+ summary = _summarize_response_structure(response)
63
+ raise ImageExtractionError(f"์ด๋ฏธ์ง€ bytes ์ถ”์ถœ ์‹คํŒจ: {summary}")
64
+
65
+
66
+ def _generate_thumbnail_texts(strategy_key, script_text, title_hint, client, text_model_id):
67
+ default_payload = {"top": "", "layout": "single", "left": "", "right": ""}
68
+ if strategy_key.startswith("A."):
69
+ instruction = """
70
+ ๋Œ€๋ณธ์„ ์ฐธ๊ณ ํ•ด์„œ ์ธ๋„ค์ผ์šฉ ์งง์€ ๋ฌธ๊ตฌ 2๊ฐœ๋ฅผ ์ž‘์„ฑํ•ด.
71
+ - ์ขŒ์ธก ์ƒ๋‹จ: ๋ถ€์ •์ /๊ณผ๊ฑฐ/์•ฝ์ž ๋ถ„์œ„๊ธฐ์˜ ์งง์€ ๋ฌธ๊ตฌ (6์ž ์ด๋‚ด)
72
+ - ์šฐ์ธก ์ƒ๋‹จ: ๊ธ์ •์ /๋ฏธ๋ž˜/๊ฐ•์ž ๋ถ„์œ„๊ธฐ์˜ ์งง์€ ๋ฌธ๊ตฌ (6์ž ์ด๋‚ด)
73
+ ์ถœ๋ ฅ ํ˜•์‹(JSON ONLY):
74
+ {"left": "...", "right": "..."}
75
+ """.strip()
76
+ elif strategy_key.startswith("B."):
77
+ instruction = """
78
+ ๋Œ€๋ณธ์„ ์ฐธ๊ณ ํ•ด์„œ ์ธ๋„ค์ผ ์ƒ๋‹จ์— ๋“ค์–ด๊ฐˆ ์„œ๋ธŒ ๋ฌธ๊ตฌ๋ฅผ ์ž‘์„ฑํ•ด.
79
+ - "์†๋ณด/๊ธด๊ธ‰/๋‹จ๋…" ๊ฐ™์€ ํ†ค
80
+ - 6์ž ์ด๋‚ด
81
+ ์ถœ๋ ฅ ํ˜•์‹(JSON ONLY):
82
+ {"top": "..."}
83
+ """.strip()
84
+ else:
85
+ instruction = """
86
+ ๋Œ€๋ณธ์„ ์ฐธ๊ณ ํ•ด์„œ ์ธ๋„ค์ผ ์ƒ๋‹จ์— ๋“ค์–ด๊ฐˆ ์„œ๋ธŒ ๋ฌธ๊ตฌ๋ฅผ ์ž‘์„ฑํ•ด.
87
+ - 6์ž ์ด๋‚ด (์—†์œผ๋ฉด ๋นˆ ๋ฌธ์ž์—ด)
88
+ ๊ทธ๋ฆฌ๊ณ  ๊ตฌ์„ฑ ์œ ํ˜•์„ ์„ ํƒํ•ด.
89
+ - ๋น„๊ต/๋Œ€์กฐ๋ฉด split
90
+ - ์Šคํ† ๋ฆฌ ํ๋ฆ„์ด๋ฉด single
91
+ ์ถœ๋ ฅ ํ˜•์‹(JSON ONLY):
92
+ {"top": "...", "layout": "split|single"}
93
+ """.strip()
94
 
95
+ prompt = f"""
96
+ ๋„ˆ๋Š” ์œ ํŠœ๋ธŒ ์ธ๋„ค์ผ ์นดํ”ผ๋ผ์ดํ„ฐ์•ผ.
97
+
98
+ [๋Œ€๋ณธ]
99
+ {script_text[:8000]}
100
+
101
+ [์ฐธ๊ณ  ์ œ๋ชฉ]
102
+ {title_hint}
103
+
104
+ [์š”๊ตฌ์‚ฌํ•ญ]
105
+ {instruction}
106
+ """.strip()
107
+
108
+ try:
109
+ response = client.models.generate_content(model=text_model_id, contents=prompt)
110
+ text = (getattr(response, "text", "") or "").strip()
111
+ except Exception as exc:
112
+ logger.exception("์ธ๋„ค์ผ ์„œ๋ธŒ ๋ฌธ๊ตฌ ์ƒ์„ฑ ์‹คํŒจ")
113
+ raise RuntimeError("์ธ๋„ค์ผ ์„œ๋ธŒ ๋ฌธ๊ตฌ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") from exc
114
+
115
+ payload = None
116
+ try:
117
+ payload = json.loads(text)
118
+ except json.JSONDecodeError:
119
+ payload = None
120
+
121
+ if not isinstance(payload, dict):
122
+ payload = {}
123
+
124
+ merged = {**default_payload, **payload}
125
+ layout = str(merged.get("layout", "single")).lower().strip()
126
+ if layout not in {"split", "single"}:
127
+ layout = "single"
128
+ merged["layout"] = layout
129
+
130
+ if strategy_key.startswith("A."):
131
+ left_text = str(merged.get("left") or "").strip()
132
+ right_text = str(merged.get("right") or "").strip()
133
+ if not left_text:
134
+ left_text = "์œ„๊ธฐ"
135
+ if not right_text:
136
+ right_text = "๊ธฐํšŒ"
137
+ return {"left_text": left_text, "right_text": right_text}
138
+
139
+ if strategy_key.startswith("B."):
140
+ top_text = str(merged.get("top") or "").strip()
141
+ if not top_text:
142
+ top_text = "์†๋ณด"
143
+ return {"top_text": top_text}
144
+
145
+ top_text = str(merged.get("top") or "").strip()
146
+ return {"top_text": top_text, "layout": merged["layout"]}
147
 
148
  def process_scene_task(
149
  index,
 
190
  3) ํ™”๋ฉด ๋ถ„ํ• (Split Screen) ๊ธˆ์ง€. ํ•œ ์žฅ๋ฉด์œผ๋กœ๋งŒ ๊ตฌ์„ฑ.
191
  4) ์ด๋ฏธ์ง€์— ๊ธ€์ž/์ž๋ง‰ ๋„ฃ์ง€ ๋งˆ๋ผ. (ํ…์ŠคํŠธ ์˜ค๋ฒ„๋ ˆ์ด ๊ธˆ์ง€)
192
  5) ์ตœ์ข… ์ถœ๋ ฅ์€ "ํ”„๋กฌํ”„ํŠธ ํ•œ ๋ฉ์–ด๋ฆฌ ํ…์ŠคํŠธ"๋งŒ. JSON/๋งˆํฌ๋‹ค์šด/๋ฒˆํ˜ธ/์ œ๋ชฉ ๊ธˆ์ง€.
193
+ 6) ๊ฒฐ๊ณผ๋Š” ์ด๋ฏธ์ง€ 1์žฅ์ด์–ด์•ผ ํ•˜๋ฉฐ, ์„ค๋ช…/๋ถ„์„ ํ…์ŠคํŠธ ์ถœ๋ ฅ ๊ธˆ์ง€.
194
+ 7) 16:9 (1280x720) ๋น„์œจ๋กœ ์ƒ์„ฑ.
195
 
196
  ์ด์ œ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ถœ๋ ฅํ•ด.
197
  """.strip()
198
 
199
 
200
+ try:
201
+ brain_res = client.models.generate_content(
202
+ model=text_model_id,
203
+ contents=brain_prompt
204
+ )
205
+ except Exception as exc:
206
+ logger.exception("์žฅ๋ฉด ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ ์‹คํŒจ")
207
+ raise RuntimeError("์žฅ๋ฉด ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") from exc
208
 
209
  # brain_res.text๊ฐ€ ์—†์„ ์ˆ˜๋„ ์žˆ์–ด ๋ฐฉ์–ด
210
  final_prompt = getattr(brain_res, "text", None) or scene_text
211
  final_prompt = re.sub(r"\s+", " ", final_prompt).strip()
212
 
213
+ # 2) Painter: ์ธ๋„ค์ผ๊ณผ ๋™์ผํ•œ ๋ฐฉ์‹์œผ๋กœ ๋ชจ๋ธ ํƒ€์ž…์— ๋งž์ถฐ ์ƒ์„ฑ
214
+ uses_gemini_image = "gemini" in (image_model_id or "").lower()
215
+
216
+ last_scene_response = {"value": None}
217
+
218
+ def _render_scene_image(prompt_text):
219
+ contents = prompt_text
220
+ if reference_image is not None:
221
+ try:
222
+ from google.genai import types
223
+ import io
224
+ buf = io.BytesIO()
225
+ reference_image.save(buf, format="PNG")
226
+ ref_part = types.Part.from_bytes(data=buf.getvalue(), mime_type="image/png")
227
+ contents = [prompt_text, ref_part]
228
+ except Exception as exc:
229
+ logger.exception("์ฐธ์กฐ ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ ์‹คํŒจ")
230
+ raise RuntimeError("์ฐธ์กฐ ์ด๋ฏธ์ง€ ์ฒ˜๋ฆฌ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") from exc
231
+
232
+ if uses_gemini_image:
233
+ try:
234
+ from google.genai import types
235
+ except Exception as exc:
236
+ logger.exception("GenerateContentConfig ๋กœ๋“œ ์‹คํŒจ")
237
+ raise RuntimeError("์ด๋ฏธ์ง€ ์ƒ์„ฑ ์„ค์ •์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.") from exc
238
+ img_res = client.models.generate_content(
239
  model=image_model_id,
240
+ contents=contents,
241
+ config=types.GenerateContentConfig(
242
+ response_modalities=["IMAGE"],
243
+ image_config=types.ImageConfig(image_size="1K")
244
+ )
245
  )
246
+ last_scene_response["value"] = img_res
247
+ return _safe_extract_image_bytes(img_res)
 
 
248
 
249
+ if hasattr(client.models, "generate_images"):
250
+ img_res = client.models.generate_images(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  model=image_model_id,
252
+ prompt=prompt_text
253
  )
254
+ last_scene_response["value"] = img_res
255
+ return _safe_extract_image_bytes(img_res)
256
+
257
+ raise RuntimeError(
258
+ "์žฅ๋ฉด ์ด๋ฏธ์ง€ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. "
259
+ "๋ชจ๋ธ/ํ˜ธ์ถœ๋ฐฉ์‹ ๋ถˆ์ผ์น˜์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. "
260
+ "Gemini ์ด๋ฏธ์ง€ ๋ชจ๋ธ์€ generateContent, Imagen ๋ชจ๋ธ์€ generate_images๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค."
261
+ )
262
 
263
+ try:
264
+ img_bytes = _render_scene_image(final_prompt)
265
+ return index, final_prompt, img_bytes
266
+ except ImageExtractionError as exc:
267
+ response = last_scene_response["value"]
268
+ prompt_feedback = getattr(response, "prompt_feedback", None)
269
+ finish_reason = None
270
+ candidates = getattr(response, "candidates", None) or []
271
+ if candidates:
272
+ finish_reason = getattr(candidates[0], "finish_reason", None)
273
+ logger.exception(
274
+ "์žฅ๋ฉด ์ด๋ฏธ์ง€ ์ถ”์ถœ ์‹คํŒจ (1์ฐจ) finish_reason=%s prompt_feedback=%s",
275
+ finish_reason,
276
+ prompt_feedback
277
+ )
278
+ if "ํ…์ŠคํŠธ๋งŒ ๋ฐ˜ํ™˜" in str(exc):
279
+ fallback_prompt = f"""
280
+ ํ•ต์‹ฌ ์žฅ๋ฉด: {scene_text}
281
+ 16:9 (1280x720). ํ•œ ์žฅ๋ฉด์œผ๋กœ๋งŒ ๊ตฌ์„ฑ. ์ด๋ฏธ์ง€ ์ƒ์„ฑ๋งŒ ์ถœ๋ ฅ.
282
+ """.strip()
283
+ try:
284
+ img_bytes = _render_scene_image(fallback_prompt)
285
+ return index, fallback_prompt, img_bytes
286
+ except ImageExtractionError:
287
+ raise
288
+ raise
289
+ except Exception as exc:
290
+ logger.exception("์žฅ๋ฉด ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์‹คํŒจ")
291
+ if uses_gemini_image:
292
+ raise RuntimeError(
293
+ "์žฅ๋ฉด ์ด๋ฏธ์ง€ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. "
294
+ "๋ชจ๋ธ/ํ˜ธ์ถœ๋ฐฉ์‹ ๋ถˆ์ผ์น˜์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. "
295
+ "Gemini ์ด๋ฏธ์ง€ ๋ชจ๋ธ์€ generateContent ๋ฐฉ์‹์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."
296
+ ) from exc
297
+ raise RuntimeError(
298
+ "์žฅ๋ฉด ์ด๋ฏธ์ง€ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. "
299
+ "๋ชจ๋ธ/ํ˜ธ์ถœ๋ฐฉ์‹ ๋ถˆ์ผ์น˜์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. "
300
+ "Imagen ๊ณ„์—ด ๋ชจ๋ธ์„ ์„ ํƒํ•˜๊ฑฐ๋‚˜ Gemini ์ด๋ฏธ์ง€ ๋ชจ๋ธ์„ ์„ ํƒํ•˜์„ธ์š”."
301
+ ) from exc
302
+
303
+ def process_thumbnail_task(
304
  index,
305
  strategy_key,
306
  strategy_text,
 
310
  text_model_id,
311
  image_model_id,
312
  aspect_ratio,
313
+ reference_image=None,
314
  ):
315
  """
316
  return (idx, final_prompt, image_bytes)
 
335
 
336
  [ํ•„์ˆ˜ ์กฐ๊ฑด]
337
  - ์ถœ๋ ฅ์€ ๋ฌด์กฐ๊ฑด ํ•œ๊ตญ์–ด๋งŒ. ์˜์–ด/๋กœ๋งˆ์ž ๊ธˆ์ง€.
 
 
338
  - ํŠน์ • ๊ตญ๊ฐ€ ์ƒ์ง• ์ž๋™์ƒ์„ฑ ๊ธˆ์ง€: ํƒœ๊ทน๊ธฐ, ์ฒญ์™€๋Œ€, ๊ตญํšŒ์˜์‚ฌ๋‹น, ๋Œ€ํ†ต๋ น, ํ›ˆ์žฅ/ํœ˜์žฅ, ์„ ๊ฑฐ ํฌ์Šคํ„ฐ, ๊ตญ๊ธฐ๋ฅ˜ ์ „๋ถ€ ๊ธˆ์ง€.
339
  - ์ •์น˜์ธ์„ ์‹ค์กด ์ธ๋ฌผ์ฒ˜๋Ÿผ ํŠน์ •ํ•˜์ง€ ๋ง๊ณ , ์ต๋ช… ์บ๋ฆญํ„ฐ/์‹ค๋ฃจ์—ฃ/์ƒ์ง•์  ์—ฐ์ถœ๋กœ ์ฒ˜๋ฆฌ.
340
  - ํ™”๋ฉด ๋ถ„ํ• ์€ ์ „๋žต์— ํฌํ•จ๋œ ๊ฒฝ์šฐ์—๋งŒ ํ—ˆ์šฉ.
341
+ - ํ™”๋ฉด๋น„: 16:9 (1280x720)
342
  - ์ตœ์ข… ์ถœ๋ ฅ์€ ํ”„๋กฌํ”„ํŠธ ํ…์ŠคํŠธ 1๊ฐœ๋งŒ. (JSON/๋งˆํฌ๋‹ค์šด/๋ฒˆํ˜ธ ๊ธˆ์ง€)
343
  """.strip()
344
 
345
+ try:
346
+ brain_res = client.models.generate_content(model=text_model_id, contents=brain_prompt)
347
+ scene_prompt = (getattr(brain_res, "text", "") or "").strip()
348
+ except Exception as exc:
349
+ logger.exception("์ธ๋„ค์ผ ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ ์‹คํŒจ")
350
+ raise RuntimeError("์ธ๋„ค์ผ ํ”„๋กฌํ”„ํŠธ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.") from exc
351
+
352
+ overlay_texts = _generate_thumbnail_texts(strategy_key, script_text, title_hint, client, text_model_id)
353
+
354
+ common_rules = f"""
355
+ - ํฌ๋งท: 16:9 (1280x720). ์ „์ฒด ํ”„๋ ˆ์ž„ ์œ ์ง€, ํฌ๋กญ/์ž˜๋ฆผ ๊ธˆ์ง€.
356
+ - ํ…์ŠคํŠธ ์˜ค๋ฒ„๋ ˆ์ด ๋ฐ˜๋“œ์‹œ ํฌํ•จ.
357
+ - ํฐํŠธ: ๊ตต์€ ๊ณ ๋”•, ํฐ ๊ธ€์”จ + ๊ฒ€์ • ์ŠคํŠธ๋กœํฌ, ๋†’์€ ๋Œ€๋น„.
358
+ - ์ƒ๋‹จ: ์งง์€ ์„œ๋ธŒ ๋ฌธ๊ตฌ.
359
+ - ํ•˜๋‹จ ์ค‘์•™: ๋ฉ”์ธ ํƒ€์ดํ‹€ \"{title_hint}\" ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ.
360
+ """.strip()
361
 
362
+ if strategy_key.startswith("A."):
363
+ overlay_rules = f"""
364
+ - ์ขŒ์ธก ์ƒ๋‹จ ๋ฌธ๊ตฌ: \"{overlay_texts['left_text']}\".
365
+ - ์šฐ์ธก ์ƒ๋‹จ ๋ฌธ๊ตฌ: \"{overlay_texts['right_text']}\".
366
+ - ํ•˜๋‹จ ์ค‘์•™ ๋ฉ”์ธ ํƒ€์ดํ‹€: \"{title_hint}\".
367
+ """.strip()
368
+ elif strategy_key.startswith("B."):
369
+ overlay_rules = f"""
370
+ - ์ƒ๋‹จ ์„œ๋ธŒ ๋ฌธ๊ตฌ: \"{overlay_texts['top_text']}\".
371
+ - ํ•˜๋‹จ ์ค‘์•™ ๋ฉ”์ธ ํƒ€์ดํ‹€: \"{title_hint}\".
372
+ """.strip()
373
+ else:
374
+ layout_text = "Split Screen" if overlay_texts["layout"] == "split" else "Single Scene"
375
+ overlay_rules = f"""
376
+ - ๊ตฌ์„ฑ: {layout_text}.
377
+ - ์ƒ๋‹จ ์„œ๋ธŒ ๋ฌธ๊ตฌ: \"{overlay_texts['top_text']}\".
378
+ - ํ•˜๋‹จ ์ค‘์•™ ๋ฉ”์ธ ๏ฟฝ๏ฟฝ์ดํ‹€: \"{title_hint}\".
379
+ """.strip()
380
 
381
+ final_prompt = f"""
382
+ [์ „๋žต ์„ค๋ช…]
383
+ {strategy_text}
 
 
 
 
 
 
 
384
 
385
+ [์žฅ๋ฉด ๊ตฌ์„ฑ]
386
+ {scene_prompt}
387
+
388
+ [๊ณตํ†ต ๊ทœ์น™]
389
+ {common_rules}
390
+
391
+ [ํ…์ŠคํŠธ ๋ฐฐ์น˜]
392
+ {overlay_rules}
393
+ """.strip()
394
+
395
+ # 2) Painter: ๋ชจ๋ธ ํŠน์„ฑ์— ๋งž๊ฒŒ ์ด๋ฏธ์ง€ ์ƒ์„ฑ
396
+ uses_gemini_image = "gemini" in (image_model_id or "").lower()
397
+
398
+ last_response = {"value": None}
399
+
400
+ def _render_image(prompt_text):
401
+ if uses_gemini_image:
402
+ try:
403
+ from google.genai import types
404
+ except Exception as exc:
405
+ logger.exception("GenerateContentConfig ๋กœ๋“œ ์‹คํŒจ")
406
+ raise RuntimeError("์ด๋ฏธ์ง€ ์ƒ์„ฑ ์„ค์ •์„ ๋ถˆ๋Ÿฌ์˜ค์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค.") from exc
407
  img_res = client.models.generate_content(
408
  model=image_model_id,
409
+ contents=prompt_text,
410
+ config=types.GenerateContentConfig(
411
+ response_modalities=["IMAGE"],
412
+ image_config=types.ImageConfig(image_size="1K")
413
+ )
414
  )
415
+ last_response["value"] = img_res
416
+ return _safe_extract_image_bytes(img_res)
417
+ if hasattr(client.models, "generate_images"):
418
+ img_res = client.models.generate_images(
419
+ model=image_model_id,
420
+ prompt=prompt_text
421
+ )
422
+ last_response["value"] = img_res
423
+ return _safe_extract_image_bytes(img_res)
424
+ raise RuntimeError(
425
+ "์ธ๋„ค์ผ ์ด๋ฏธ์ง€ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. "
426
+ "๋ชจ๋ธ/ํ˜ธ์ถœ๋ฐฉ์‹ ๋ถˆ์ผ์น˜์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. "
427
+ "Gemini ์ด๋ฏธ์ง€ ๋ชจ๋ธ์€ generateContent, Imagen ๋ชจ๋ธ์€ generate_images๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค."
428
+ )
429
 
430
+ try:
431
+ img_bytes = _render_image(final_prompt)
432
+ return index, final_prompt, img_bytes
433
+ except ImageExtractionError as exc:
434
+ response = last_response["value"]
435
+ prompt_feedback = getattr(response, "prompt_feedback", None)
436
+ finish_reason = None
437
+ candidates = getattr(response, "candidates", None) or []
438
+ if candidates:
439
+ finish_reason = getattr(candidates[0], "finish_reason", None)
440
+ logger.exception(
441
+ "์ธ๋„ค์ผ ์ด๋ฏธ์ง€ ์ถ”์ถœ ์‹คํŒจ (1์ฐจ) finish_reason=%s prompt_feedback=%s",
442
+ finish_reason,
443
+ prompt_feedback
444
+ )
445
+ if not strategy_key.startswith("B."):
446
+ raise
447
+ fallback_prompt = f"""
448
+ 16:9 ์œ ํŠœ๋ธŒ ์ธ๋„ค์ผ. ํ˜„์‹ค ๋‹คํ ๋‰ด์Šค ์‚ฌ์ง„ ๋ฐฐ๊ฒฝ.
449
+ ์ „๊ฒฝ์— 2D ์Šคํ‹ฑ๋งจ์ด ์ถฉ๊ฒฉ/๊ฐ•์กฐ ๋ฆฌ์•ก์…˜.
450
+ ํ…์ŠคํŠธ ์˜ค๋ฒ„๋ ˆ์ด ํฌํ•จ: ์ƒ๋‹จ \"{overlay_texts.get('top_text', '')}\",
451
+ ํ•˜๋‹จ ์ค‘์•™ \"{title_hint}\". ํฐ ๊ธ€์”จ + ๊ฒ€์ • ์ŠคํŠธ๋กœํฌ, ๊ตต์€ ๊ณ ๋”•.
452
+ """.strip()
453
+ try:
454
+ img_bytes = _render_image(fallback_prompt)
455
+ return index, fallback_prompt, img_bytes
456
+ except ImageExtractionError as retry_exc:
457
+ logger.exception("์ธ๋„ค์ผ ์ด๋ฏธ์ง€ ์ถ”์ถœ ์‹คํŒจ (ํด๋ฐฑ)")
458
+ raise retry_exc from exc
459
+ except Exception as exc:
460
+ logger.exception("์ธ๋„ค์ผ ์ด๋ฏธ์ง€ ์ƒ์„ฑ ์‹คํŒจ")
461
+ if uses_gemini_image:
462
+ raise RuntimeError(
463
+ "์ธ๋„ค์ผ ์ด๋ฏธ์ง€ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. "
464
+ "๋ชจ๋ธ/ํ˜ธ์ถœ๋ฐฉ์‹ ๋ถˆ์ผ์น˜์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. "
465
+ "Gemini ์ด๋ฏธ์ง€ ๋ชจ๋ธ์€ generateContent ๋ฐฉ์‹์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค."
466
+ ) from exc
467
+ raise RuntimeError(
468
+ "์ธ๋„ค์ผ ์ด๋ฏธ์ง€ ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. "
469
+ "๋ชจ๋ธ/ํ˜ธ์ถœ๋ฐฉ์‹ ๋ถˆ์ผ์น˜์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. "
470
+ "Imagen ๊ณ„์—ด ๋ชจ๋ธ์„ ์„ ํƒํ•˜๊ฑฐ๋‚˜ Gemini ์ด๋ฏธ์ง€ ๋ชจ๋ธ์„ ์„ ํƒํ•˜์„ธ์š”."
471
+ ) from exc
utils.py CHANGED
@@ -20,4 +20,19 @@ def group_sentences(text, target_sec=10):
20
  def get_smart_filename(index, text):
21
  clean_text = re.sub(r'[\\/*?:"<>|]', "", text).strip()
22
  name_part = clean_text[:12] if len(clean_text) <= 12 else f"{clean_text[:6]}~{clean_text[-4:]}"
23
- return f"{index+1:02d}_{name_part}.png"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  def get_smart_filename(index, text):
21
  clean_text = re.sub(r'[\\/*?:"<>|]', "", text).strip()
22
  name_part = clean_text[:12] if len(clean_text) <= 12 else f"{clean_text[:6]}~{clean_text[-4:]}"
23
+ return f"{index+1:02d}_{name_part}.png"
24
+
25
+ def make_scene_snippet(text, head_len=10, tail_len=10, max_len=60):
26
+ clean_text = " ".join((text or "").strip().split())
27
+ clean_text = re.sub(r'[\\/*?:"<>|]', "", clean_text)
28
+ clean_text = re.sub(r"[^0-9A-Za-z๊ฐ€-ํžฃ\s.~]", " ", clean_text)
29
+ clean_text = re.sub(r"[.]{2,}", ".", clean_text)
30
+ clean_text = re.sub(r"\s+", " ", clean_text).strip()
31
+ if not clean_text:
32
+ return "scene"
33
+ if len(clean_text) <= head_len + tail_len + 1:
34
+ snippet = clean_text
35
+ else:
36
+ snippet = f"{clean_text[:head_len]}~{clean_text[-tail_len:]}"
37
+ snippet = re.sub(r"\s+", " ", snippet).strip()
38
+ return snippet[:max_len].rstrip(" .")