PLXR's picture
Update app.py
7704add verified
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")