Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import streamlit.components.v1 as components | |
| import os | |
| import io | |
| import concurrent.futures | |
| import datetime | |
| import zipfile | |
| import json | |
| from PIL import Image | |
| from google import genai | |
| # ๋ก์ง ๋ชจ๋ ์ํฌํธ | |
| import logic_image | |
| import logic_seo | |
| import logic_tts | |
| import utils | |
| from config_style import STYLE_DEFINITIONS, THUMBNAIL_STRATEGIES | |
| # ====================================================== | |
| # 1. ํ์ด์ง ์ค์ ๋ฐ CSS ๋์์ธ | |
| # ====================================================== | |
| st.set_page_config( | |
| page_title="Nano Banana Studio Pro (Chapter Mode)", | |
| page_icon="๐", | |
| layout="wide", | |
| initial_sidebar_state="expanded" | |
| ) | |
| # ------------------------------------------------------ | |
| # [ํต์ฌ] ์ธ์ ์ํ ์ด๊ธฐํ (์ฑํฐ ๊ด๋ฆฌ์ฉ) | |
| # ------------------------------------------------------ | |
| if 'chapters' not in st.session_state: | |
| st.session_state['chapters'] = [ | |
| { | |
| "id": 0, | |
| "text": "", | |
| "results": [] | |
| } | |
| ] | |
| if 'next_chapter_id' not in st.session_state: | |
| st.session_state['next_chapter_id'] = 1 | |
| if 'final_audio' not in st.session_state: | |
| st.session_state['final_audio'] = None | |
| if 'thumbnail_results' not in st.session_state: | |
| st.session_state['thumbnail_results'] = [] | |
| if 'thumbnail_text' not in st.session_state: | |
| st.session_state['thumbnail_text'] = "" | |
| if 'seo_result' not in st.session_state: | |
| st.session_state['seo_result'] = None | |
| # [CSS] ๋คํฌ๋ชจ๋ + ์ค๋ ์ง ํฌ์ธํธ ๋์์ธ | |
| st.markdown(""" | |
| <style> | |
| @import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css'); | |
| html, body, [class*="css"] { | |
| font-family: 'Pretendard', sans-serif; | |
| } | |
| .stApp { | |
| background-color: #121212; | |
| color: #E0E0E0; | |
| } | |
| /* ์ฌ์ด๋๋ฐ */ | |
| section[data-testid="stSidebar"] { | |
| background-color: #0A0A0A; | |
| border-right: 1px solid #333; | |
| } | |
| /* ์ ๋ ฅ์ฐฝ ๊ฐ๋ ์ฑ ๊ฐ์ */ | |
| .stTextInput input, .stTextArea textarea, .stSelectbox div[data-baseweb="select"] > div { | |
| background-color: #252525 !important; | |
| color: #FFFFFF !important; | |
| border: 1px solid #404040 !important; | |
| border-radius: 8px !important; | |
| } | |
| .stTextArea textarea[disabled] { | |
| background-color: #1E1E1E !important; | |
| color: #AAAAAA !important; | |
| border: 1px solid #333 !important; | |
| } | |
| ::placeholder { | |
| color: #AAAAAA !important; | |
| opacity: 1; | |
| } | |
| /* ํญ ๋์์ธ */ | |
| button[data-baseweb="tab"] { | |
| font-size: 16px; | |
| font-weight: 700; | |
| color: #888; | |
| } | |
| button[data-baseweb="tab"][aria-selected="true"] { | |
| color: #FFC107 !important; | |
| background-color: transparent !important; | |
| border-bottom: 2px solid #FFC107 !important; | |
| } | |
| /* ๋ฒํผ ๋์์ธ */ | |
| div.stButton > button[kind="primary"] { | |
| background: linear-gradient(135deg, #FFC107 0%, #FF9800 100%); | |
| color: #000000; | |
| border: none; | |
| padding: 14px 24px; | |
| font-size: 16px; | |
| font-weight: 800; | |
| border-radius: 8px; | |
| transition: all 0.3s ease; | |
| } | |
| div.stButton > button[kind="primary"]:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 15px rgba(255, 193, 7, 0.4); | |
| } | |
| div.stButton > button[kind="secondary"] { | |
| border-color: #555; | |
| color: #ddd; | |
| } | |
| h1, h2, h3, h4, h5 { color: #FFFFFF !important; } | |
| .highlight { color: #FFC107; } | |
| div[data-testid="stExpander"] { | |
| background-color: #1E1E1E; | |
| border: 1px solid #333; | |
| border-radius: 8px; | |
| } | |
| /* ์ฑํฐ ๊ตฌ๋ถ์ */ | |
| hr { border-color: #333; margin: 30px 0; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ====================================================== | |
| # 2. ์ฌ์ด๋๋ฐ (ํตํฉ ์ค์ ) | |
| # ====================================================== | |
| with st.sidebar: | |
| st.markdown("## โ๏ธ ๊ธฐ๋ณธ ์ค์ (Basic)") | |
| api_key = st.text_input("Google API Key", type="password", placeholder="API ํค๋ฅผ ์ ๋ ฅํ์ธ์...", label_visibility="collapsed") | |
| st.divider() | |
| st.markdown("### ๐จ Model Engine") | |
| st.radio("์ฌ์ฉํ ๋ชจ๋ธ", ["๐ Pro (Imagen 3)"], label_visibility="collapsed") | |
| # [๊ณ ์ ] Pro ๋ชจ๋ธ๋ง ์ฌ์ฉ | |
| image_model_id = "gemini-3-pro-image-preview" | |
| text_model_id = "gemini-3-pro-preview" | |
| st.divider() | |
| st.markdown("### ๐ Canvas Ratio") | |
| ar_radio = st.radio("๋น์จ", ["16:9", "9:16"], label_visibility="collapsed", horizontal=True) | |
| aspect_ratio = ar_radio | |
| st.divider() | |
| st.markdown("### ๐ ๏ธ ๊ณ ๊ธ ์ค์ (TTS & Split)") | |
| st.caption("TTS Model ID") | |
| tts_model_id = st.text_input("TTS ID", value="gemini-2.5-pro-preview-tts", label_visibility="collapsed") | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.caption("Image Scene Split (์ด๋ฏธ์ง ์์ฑ์ฉ)") | |
| duration_per_scene = st.slider("์ฅ๋ฉด๋น ์๊ฐ(์ด)", 5, 600, 5, step=5) | |
| split_criteria = duration_per_scene * 8 | |
| st.info(f"๐ก {duration_per_scene}์ด (์ฝ {split_criteria}์) ๋จ์๋ก ์ฅ๋ฉด์ ๋๋๋๋ค.") | |
| st.caption("โป TTS๋ ์ค์ ๊ณผ ๋ฌด๊ดํ๊ฒ 500์ ๋จ์๋ก ์ต์ ํ๋ฉ๋๋ค.") | |
| st.divider() | |
| st.markdown("## ๐จ ์คํ์ผ ๋ฐ ์บ๋ฆญํฐ ์ค์ ") | |
| st.caption("์์ ์ ์ฒด์ ๋ถ์๊ธฐ์ ์บ๋ฆญํฐ๋ฅผ ๊ฒฐ์ ํฉ๋๋ค.") | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.markdown("#### ๐๏ธ ํํ(Style) ์ ํ") | |
| style_options = list(STYLE_DEFINITIONS.keys()) + ["์ง์ ์ ๋ ฅ"] | |
| selected_style = st.selectbox("์คํ์ผ ์ ํ", style_options, label_visibility="collapsed") | |
| custom_style_input = "" | |
| current_prompt = "" | |
| if selected_style == "์ง์ ์ ๋ ฅ": | |
| custom_style_input = st.text_input("์คํ์ผ ํ๋กฌํํธ", placeholder="์: ์ง๋ธ๋ฆฌ ์คํ์ผ, ์์ฑํํ") | |
| current_prompt = custom_style_input if custom_style_input else "(ํ๋กฌํํธ๋ฅผ ์ ๋ ฅํด์ฃผ์ธ์)" | |
| else: | |
| style_value = STYLE_DEFINITIONS[selected_style] | |
| if isinstance(style_value, dict): | |
| current_prompt = style_value.get('prompt', str(style_value)) | |
| else: | |
| current_prompt = str(style_value) | |
| st.markdown("#### ๐ ์ ์ฉ๋ ํ๋กฌํํธ (๋ฏธ๋ฆฌ๋ณด๊ธฐ)") | |
| st.text_area("ํ๋กฌํํธ ๋ฏธ๋ฆฌ๋ณด๊ธฐ", value=current_prompt, height=150, disabled=True, label_visibility="collapsed") | |
| st.divider() | |
| st.markdown("#### ๐ค ์บ๋ฆญํฐ ์ฐธ์กฐ (Reference)") | |
| uploaded_file = st.file_uploader("์ด๋ฏธ์ง ์ ๋ก๋", type=["png", "jpg", "jpeg"], label_visibility="collapsed") | |
| reference_image = None | |
| if uploaded_file: | |
| reference_image = Image.open(uploaded_file) | |
| st.image(reference_image, caption="์ฐธ์กฐ ์ด๋ฏธ์ง", use_container_width=True) | |
| # ====================================================== | |
| # 3. ๋ฉ์ธ ์ฝํ ์ธ | |
| # ====================================================== | |
| st.title("Nano Banana <span class='highlight'>Chapter Studio</span>", anchor=False) | |
| st.markdown("<div style='margin-top: -15px; color: #888; margin-bottom: 20px;'>AI Powered All-in-One Workspace</div>", unsafe_allow_html=True) | |
| # ํฌํผ ํจ์: ์ ์ฒด ์คํฌ๋ฆฝํธ ํฉ์น๊ธฐ (์ปจํ ์คํธ์ฉ) | |
| def get_full_script(): | |
| full_text = [] | |
| for ch in st.session_state['chapters']: | |
| if ch['text'].strip(): | |
| full_text.append(ch['text']) | |
| return "\n\n".join(full_text) | |
| tab1, tab2, tab3 = st.tabs(["๐ฌ ์ฑํฐ๋ณ ์ฅ๋ฉด ์์ฑ", "๐ ๊ธฐํ/SEO", "๐๏ธ ์ฑ์ฐ/TTS"]) | |
| # ---------------------------------------------------------------- | |
| # [TAB 1] ์ฑํฐ๋ณ ์ฅ๋ฉด/์ด๋ฏธ์ง ์์ฑ (๋ฉ์ธ ๊ธฐ๋ฅ) | |
| # ---------------------------------------------------------------- | |
| with tab1: | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| # ์ฑํฐ ๊ด๋ฆฌ ๋ฃจํ | |
| chapters_to_remove = [] | |
| for idx, chapter in enumerate(st.session_state['chapters']): | |
| with st.container(): | |
| c_head1, c_head2 = st.columns([8, 1]) | |
| with c_head1: | |
| st.markdown(f"#### ๐ Chapter {idx + 1}") | |
| with c_head2: | |
| if st.button("๐๏ธ ์ญ์ ", key=f"del_ch_{chapter['id']}"): | |
| chapters_to_remove.append(idx) | |
| # ๋๋ณธ ์ ๋ ฅ | |
| new_text = st.text_area( | |
| f"Chapter {idx + 1} ๋๋ณธ", | |
| value=chapter['text'], | |
| height=150, | |
| placeholder="์ด ์ฑํฐ์ ๋ด์ฉ์ ์ ๋ ฅํ์ธ์.", | |
| key=f"text_ch_{chapter['id']}", | |
| label_visibility="collapsed" | |
| ) | |
| # ์ ๋ ฅ๊ฐ ์ฆ์ ๋๊ธฐํ | |
| st.session_state['chapters'][idx]['text'] = new_text | |
| # ์์ฑ ๋ฒํผ | |
| if st.button(f"๐ Chapter {idx + 1} ์ฅ๋ฉด ์์ฑํ๊ธฐ", key=f"gen_ch_{chapter['id']}", type="primary"): | |
| if not api_key: st.error("API Key๊ฐ ํ์ํฉ๋๋ค.") | |
| elif not new_text.strip(): st.warning("๋๋ณธ์ ์ ๋ ฅํด์ฃผ์ธ์.") | |
| else: | |
| # [์์ ] 300์ ์ ํ ์ ์ฉ (API ๋ถํ ๊ฐ์) | |
| full_context = new_text[:300] | |
| # ํ ์คํธ ๋ถํ | |
| raw_scenes = logic_tts.split_text_smartly(new_text, limit=split_criteria) | |
| scenes_text = raw_scenes # ์ ํ ์์ | |
| st.toast(f"๐ ์ฑํฐ {idx+1}: {len(scenes_text)}๊ฐ ์ฅ๋ฉด ์์ฑ ์์!") | |
| temp_results = [None] * len(scenes_text) | |
| def run_scene_task(task_index, task_text): | |
| client = genai.Client(api_key=api_key) | |
| return logic_image.process_scene_task( | |
| task_index, | |
| {'text': task_text, 'full_script': full_context}, | |
| selected_style, | |
| custom_style_input, | |
| client, | |
| text_model_id, | |
| image_model_id, | |
| aspect_ratio, | |
| reference_image | |
| ) | |
| p_bar = st.progress(0, text=f"Chapter {idx+1} ๊ทธ๋ฆฌ๋ ์ค...") | |
| # [์์ ] ๋์์ฑ 2๋ก ์ ํ (์์ ๋ชจ๋) | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor: | |
| future_to_idx = {executor.submit(run_scene_task, i, t): i for i, t in enumerate(scenes_text)} | |
| completed_cnt = 0 | |
| for future in concurrent.futures.as_completed(future_to_idx): | |
| f_idx = future_to_idx[future] | |
| try: | |
| # [์์ ] timeout ์ค์ (45์ด) - ๋ฌดํ ๋๊ธฐ ๋ฐฉ์ง | |
| _, prompt, img, video_prompt = future.result(timeout=45) | |
| temp_results[f_idx] = { | |
| "prompt": prompt, | |
| "img_bytes": img, | |
| "script_text": scenes_text[f_idx], | |
| "video_prompt": video_prompt | |
| } | |
| completed_cnt += 1 | |
| p_bar.progress(completed_cnt / len(scenes_text), text=f"Scene {f_idx+1} ์๋ฃ!") | |
| except concurrent.futures.TimeoutError: | |
| st.error(f"Scene {f_idx+1} ์๊ฐ ์ด๊ณผ (45์ด) - ์๋ฒ ์๋ต ์์") | |
| completed_cnt += 1 # ์งํ๋ฐ๋ ๋๊ฒจ์ค | |
| except Exception as e: | |
| st.error(f"Scene {f_idx+1} ์คํจ: {e}") | |
| # ์๋ฌ ๋ด์ฉ์ ๋ ์์ธํ ๋ณด๊ธฐ ์ํด ํ๋ฉด์ ์ถ๋ ฅ | |
| st.write(f"๐ [Debug] Scene {f_idx+1} Error Detail: {type(e).__name__}, {str(e)}") | |
| completed_cnt += 1 | |
| st.session_state['chapters'][idx]['results'] = temp_results | |
| p_bar.empty() | |
| st.rerun() | |
| # ๊ฒฐ๊ณผ๋ฌผ ํ์ | |
| results = chapter.get('results', []) | |
| if results: | |
| st.info(f"โ {len(results)}๊ฐ์ ์ฅ๋ฉด์ด ์์ฑ๋์์ต๋๋ค.") | |
| with st.expander(f"๐ผ๏ธ Chapter {idx + 1} ๊ฒฐ๊ณผ๋ฌผ ํ์ธํ๊ธฐ (ํด๋ฆญ)", expanded=True): | |
| for r_idx, item in enumerate(results): | |
| if not item: continue | |
| col_img, col_desc = st.columns([1, 2]) | |
| snippet = utils.make_scene_snippet(item.get("script_text", "")) | |
| base_filename = f"ch{idx+1}_sc{r_idx+1:02d}_{snippet}" | |
| with col_img: | |
| if item.get("img_bytes"): | |
| st.image(item["img_bytes"], use_container_width=True) | |
| d1, d2 = st.columns(2) | |
| with d1: | |
| st.download_button("๐ฅ ์ด๋ฏธ์ง", data=item["img_bytes"], file_name=f"{base_filename}.png", mime="image/png", key=f"dl_img_{chapter['id']}_{r_idx}") | |
| with d2: | |
| if item.get("video_prompt"): | |
| st.download_button("๐ ํ๋กฌํํธ", data=item["video_prompt"], file_name=f"{base_filename}.txt", mime="text/plain", key=f"dl_txt_{chapter['id']}_{r_idx}") | |
| with col_desc: | |
| st.markdown(f"**Scene {r_idx + 1}**") | |
| st.caption(item.get("script_text", "")) | |
| st.text_area("๋น๋์ค ํ๋กฌํํธ", item.get("video_prompt", ""), height=80, key=f"vp_{chapter['id']}_{r_idx}") | |
| st.divider() | |
| if chapters_to_remove: | |
| for rm_idx in sorted(chapters_to_remove, reverse=True): | |
| del st.session_state['chapters'][rm_idx] | |
| st.rerun() | |
| if st.button("โ ์ฑํฐ ์ถ๊ฐํ๊ธฐ", use_container_width=True): | |
| st.session_state['chapters'].append({ | |
| "id": st.session_state['next_chapter_id'], | |
| "text": "", | |
| "results": [] | |
| }) | |
| st.session_state['next_chapter_id'] += 1 | |
| st.rerun() | |
| # ํตํฉ ๋ค์ด๋ก๋ | |
| st.markdown("<br><br>", unsafe_allow_html=True) | |
| st.subheader("๐ฆ ์ ์ฒด ๊ฒฐ๊ณผ๋ฌผ ๋ค์ด๋ก๋") | |
| total_files = 0 | |
| all_zip_buffer = io.BytesIO() | |
| has_content = False | |
| with zipfile.ZipFile(all_zip_buffer, "w") as zip_file: | |
| for ch_idx, ch in enumerate(st.session_state['chapters']): | |
| for sc_idx, res in enumerate(ch.get('results', [])): | |
| if not res: continue | |
| snippet = utils.make_scene_snippet(res.get("script_text", "")) | |
| base_name = f"ch{ch_idx+1:02d}_sc{sc_idx+1:02d}_{snippet}" | |
| if res.get("img_bytes"): | |
| zip_file.writestr(f"{base_name}.png", res["img_bytes"]) | |
| has_content = True | |
| total_files += 1 | |
| if res.get("video_prompt"): | |
| zip_file.writestr(f"{base_name}.txt", res["video_prompt"]) | |
| all_zip_buffer.seek(0) | |
| if has_content: | |
| timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M") | |
| st.download_button( | |
| label=f"โฌ๏ธ ์ ์ฒด ์ฑํฐ ๋ค์ด๋ก๋ (ZIP) - ์ด {total_files}์ฅ", | |
| data=all_zip_buffer.getvalue(), | |
| file_name=f"full_project_{timestamp}.zip", | |
| mime="application/zip", | |
| type="primary", | |
| use_container_width=True | |
| ) | |
| else: | |
| st.caption("์์ฑ๋ ๊ฒฐ๊ณผ๋ฌผ์ด ์์ต๋๋ค.") | |
| # ---------------------------------------------------------------- | |
| # [TAB 2] ์ ํ๋ธ ๊ธฐํ (SEO) | |
| # ---------------------------------------------------------------- | |
| with tab2: | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.markdown("#### ๐ ์ ์ฒด ๋๋ณธ SEO ๋ถ์") | |
| full_script = get_full_script() | |
| st.text_area("์ ์ฒด ๋๋ณธ (์๋ ํฉ์นจ)", value=full_script, height=200, disabled=True) | |
| if st.button("โจ ์ ์ฒด ๋๋ณธ์ผ๋ก SEO ๊ธฐํ์ ์์ฑ", key="seo_btn", type="primary"): | |
| if not api_key: st.error("API Key ํ์") | |
| elif not full_script.strip(): st.warning("๋๋ณธ ๋ด์ฉ์ด ์์ต๋๋ค.") | |
| else: | |
| with st.spinner("Brain ๋ชจ๋ธ์ด ์ ์ฒด ๋ด์ฉ์ ๋ถ์ ์ค์ ๋๋ค..."): | |
| client = genai.Client(api_key=api_key) | |
| seo_result = logic_seo.generate_seo_content(client, text_model_id, full_script) | |
| st.session_state["seo_result"] = seo_result | |
| st.success("๋ถ์ ์๋ฃ!") | |
| c_seo1, c_seo2 = st.columns(2) | |
| with c_seo1: | |
| st.markdown("##### ๐ ์ถ์ฒ ์ ๋ชฉ") | |
| for t in seo_result['titles']: st.info(f"{t}") | |
| with c_seo2: | |
| st.markdown("##### ๐ท๏ธ ์ถ์ฒ ํ๊ทธ") | |
| st.code(", ".join(seo_result['tags'])) | |
| st.markdown("##### ๐ ์ค๋ช ๋") | |
| st.text_area("์ค๋ช ๋ ๊ฒฐ๊ณผ", seo_result['description'], height=200) | |
| # ์ธ๋ค์ผ ์น์ | |
| st.divider() | |
| st.subheader("๐ผ๏ธ ์ธ๋ค์ผ ์์ฑ") | |
| if st.session_state.get("seo_result"): | |
| # (์ธ๋ค์ผ ๋ก์ง์ ๊ธฐ์กด ์ฝ๋ ์ฌ์ฉ) | |
| pass | |
| # ---------------------------------------------------------------- | |
| # [TAB 3] AI ์ฑ์ฐ (TTS) | |
| # ---------------------------------------------------------------- | |
| with tab3: | |
| st.markdown("<br>", unsafe_allow_html=True) | |
| st.markdown("#### ๐๏ธ ์ ์ฒด ๋๋ณธ TTS") | |
| # 1. ์ ์ฒด ๋๋ณธ ํฉ์น๊ธฐ (๊ธฐ๋ณธ๊ฐ) | |
| full_script = get_full_script() | |
| # 2. ์ฌ์ฉ์ ์ ๋ ฅ์ฐฝ | |
| tts_script = st.text_area("๋ญ๋ ํ ๋๋ณธ ์ ๋ ฅ (์์ ๊ฐ๋ฅ)", value=full_script, height=200) | |
| voice_map = { | |
| "Charon (๋จ์ฑ/๋คํ)": "Charon", "Puck (๋จ์ฑ/์พํ)": "Puck", | |
| "Kore (์ฌ์ฑ/์ฐจ๋ถ)": "Kore", "Fenrir (๋จ์ฑ/๊ฐํจ)": "Fenrir", | |
| "Aoede (์ฌ์ฑ/๋์)": "Aoede", "Orus (๋จ์ฑ/์คํ)": "Orus" | |
| } | |
| v_sel = st.selectbox("์ฑ์ฐ ์ ํ", list(voice_map.keys()), key="tts_voice_sel_main") | |
| voice_opt = voice_map[v_sel] | |
| if st.button("๐๏ธ ์ ์ฒด ๋ น์ ์์", key="tts_full_btn_main", type="primary"): | |
| if not api_key: st.error("API Key ํ์") | |
| elif not tts_script.strip(): st.warning("๋๋ณธ ๋ด์ฉ์ด ์์ต๋๋ค.") | |
| else: | |
| chunks = logic_tts.split_text_smartly(tts_script, limit=500) | |
| audio_res = [None] * len(chunks) | |
| with st.status("๋ น์ ์งํ ์ค...", expanded=True): | |
| with concurrent.futures.ThreadPoolExecutor() as executor: | |
| def run_tts_task(task_index, chunk): | |
| client = genai.Client(api_key=api_key) | |
| return logic_tts.process_tts_task(task_index, chunk, client, tts_model_id, voice_opt) | |
| f_map = {executor.submit(run_tts_task, i, c): i for i, c in enumerate(chunks)} | |
| for f in concurrent.futures.as_completed(f_map): | |
| idx, dat = f.result() | |
| if isinstance(dat, bytes): | |
| audio_res[idx] = dat | |
| st.write(f"โ Part {idx+1} ์๋ฃ") | |
| final_wav = logic_tts.merge_wav_bytes(audio_res) | |
| if final_wav: | |
| st.success("์ค๋์ค ์์ฑ ์๋ฃ!") | |
| st.audio(final_wav, format="audio/wav") | |
| st.download_button("๋ค์ด๋ก๋ (WAV)", final_wav, "full_audio.wav", "audio/wav") | |