diff --git a/app.py b/app.py index 9e06d10..cdfe1f0 100644 --- a/app.py +++ b/app.py @@ -133,6 +133,20 @@ if "view_mode" not in st.session_state: st.session_state.view_mode = "workspace" # workspace, history, settings if "selected_img_provider" not in st.session_state: st.session_state.selected_img_provider = "shubiaobiao" +if "ui_rev" not in st.session_state: + # used to scope Streamlit widget keys per project load to avoid cross-project state pollution + st.session_state.ui_rev = int(time.time() * 1000) + + +def _ui_key(suffix: str) -> str: + """ + Build a project-scoped widget key. + Streamlit widget state is keyed globally; without project scoping, switching projects can + reuse old widget states and even trigger frontend DOM errors (e.g. removeChild NotFoundError). + """ + pid = st.session_state.get("project_id") or "NEW" + rev = st.session_state.get("ui_rev") or 0 + return f"p:{pid}|r:{rev}|{suffix}" def load_project(project_id): """Load project state from DB""" @@ -142,6 +156,8 @@ def load_project(project_id): return st.session_state.project_id = project_id + # bump UI revision to ensure all widget keys are isolated per project load + st.session_state.ui_rev = int(time.time() * 1000) st.session_state.script_data = data.get("script_data") st.session_state.view_mode = "workspace" @@ -384,20 +400,21 @@ if st.session_state.view_mode == "workspace": col1, col2 = st.columns([1, 1]) with col1: - product_name = st.text_input("商品标题", value=default_name) - category = st.text_input("商品类目", value=default_category) - price = st.text_input("价格", value=default_price) + product_name = st.text_input("商品标题", value=default_name, key=_ui_key("product_name")) + category = st.text_input("商品类目", value=default_category, key=_ui_key("category")) + price = st.text_input("价格", value=default_price, key=_ui_key("price")) with col2: - tags = st.text_area("评价标签 (用于提炼卖点)", value=default_tags, height=100) - params = st.text_area("商品参数", value=default_params, height=100) + tags = st.text_area("评价标签 (用于提炼卖点)", value=default_tags, height=100, key=_ui_key("tags")) + params = st.text_area("商品参数", value=default_params, height=100, key=_ui_key("params")) # 商家自定义风格提示 style_hint = st.text_area( "商品视频重点增强提示 (可选)", value=loaded_info.get("style_hint", ""), placeholder="例如:韩风、高级感、活力青春、简约日系...", - height=80 + height=80, + key=_ui_key("style_hint"), ) st.markdown("### 上传素材") @@ -418,7 +435,7 @@ if st.session_state.view_mode == "workspace": # Model Selection (all support images; user explicitly chooses model) model_options = ["GPT-5.2", "Gemini 3 Pro", "Doubao Pro (Vision)"] - selected_model_label = st.radio("选择脚本生成模型", model_options, horizontal=True, index=0) + selected_model_label = st.radio("选择脚本生成模型", model_options, horizontal=True, index=0, key=_ui_key("script_model")) # Map label to provider key if selected_model_label == "GPT-5.2": model_provider = "shubiaobiao_gpt" @@ -548,17 +565,17 @@ if st.session_state.view_mode == "workspace": for i, item in enumerate(timeline): c1, c2, c3, c4 = st.columns([3, 3, 1, 1]) with c1: - item["text"] = st.text_input(f"旁白 #{i+1}", value=item.get("text", ""), key=f"tl_vo_{i}") + item["text"] = st.text_input(f"旁白 #{i+1}", value=item.get("text", ""), key=_ui_key(f"tl_vo_{i}")) with c2: - item["subtitle"] = st.text_input(f"字幕 #{i+1}", value=item.get("subtitle", item.get("text", "")), key=f"tl_sub_{i}") + item["subtitle"] = st.text_input(f"字幕 #{i+1}", value=item.get("subtitle", item.get("text", "")), key=_ui_key(f"tl_sub_{i}")) with c3: # 兼容旧格式: 如果有 start_ratio 则转换为 start_time default_start = item.get("start_time", item.get("start_ratio", 0) * 12) # 假设总时长12秒 - item["start_time"] = st.number_input(f"开始(秒) #{i+1}", value=float(default_start), min_value=0.0, max_value=30.0, step=0.5, key=f"tl_start_{i}") + item["start_time"] = st.number_input(f"开始(秒) #{i+1}", value=float(default_start), min_value=0.0, max_value=30.0, step=0.5, key=_ui_key(f"tl_start_{i}")) with c4: # 兼容旧格式: 如果有 duration_ratio 则转换为 duration default_dur = item.get("duration", item.get("duration_ratio", 0.25) * 12) # 假设总时长12秒 - item["duration"] = st.number_input(f"时长(秒) #{i+1}", value=float(default_dur), min_value=0.5, max_value=15.0, step=0.5, key=f"tl_dur_{i}") + item["duration"] = st.number_input(f"时长(秒) #{i+1}", value=float(default_dur), min_value=0.5, max_value=15.0, step=0.5, key=_ui_key(f"tl_dur_{i}")) # 清理旧字段 item.pop("start_ratio", None) item.pop("duration_ratio", None) @@ -577,8 +594,8 @@ if st.session_state.view_mode == "workspace": c_vis, c_aud = st.columns([2, 1]) with c_vis: - new_visual = st.text_area(f"Visual Prompt (Scene {scene['id']})", value=scene.get("visual_prompt", ""), height=80, key=f"vp_{i}") - new_video = st.text_area(f"Video Prompt (Scene {scene['id']})", value=scene.get("video_prompt", ""), height=80, key=f"vidp_{i}") + new_visual = st.text_area(f"Visual Prompt (Scene {scene['id']})", value=scene.get("visual_prompt", ""), height=80, key=_ui_key(f"vp_{i}")) + new_video = st.text_area(f"Video Prompt (Scene {scene['id']})", value=scene.get("video_prompt", ""), height=80, key=_ui_key(f"vidp_{i}")) scene["visual_prompt"] = new_visual scene["video_prompt"] = new_video @@ -589,7 +606,7 @@ if st.session_state.view_mode == "workspace": new_ft_text = st.text_input( f"Fancy Text (Scene {scene['id']})", value=ft.get("text", ""), - key=f"ft_{i}", + key=_ui_key(f"ft_{i}"), ) # 兼容:旧数据可能没有 fancy_text 字段 if not isinstance(scene.get("fancy_text"), dict): @@ -652,7 +669,7 @@ if st.session_state.view_mode == "workspace": if not st.session_state.scene_images: # Model Selection for Image Gen img_model_options = ["Shubiaobiao (Gemini)", "Doubao (Volcengine)", "Gemini (Direct)", "Doubao (Group Image)"] - selected_img_model = st.radio("选择生图模型", img_model_options, horizontal=True) + selected_img_model = st.radio("选择生图模型", img_model_options, horizontal=True, key=_ui_key("img_model")) # Map to provider if "Group Image" in selected_img_model: @@ -1116,17 +1133,17 @@ if st.session_state.view_mode == "workspace": for i, item in enumerate(timeline): c1, c2, c3, c4 = st.columns([3, 3, 1, 1]) with c1: - item["text"] = st.text_input(f"旁白 #{i+1}", value=item.get("text", ""), key=f"tune_vo_{i}") + item["text"] = st.text_input(f"旁白 #{i+1}", value=item.get("text", ""), key=_ui_key(f"tune_vo_{i}")) with c2: - item["subtitle"] = st.text_input(f"字幕 #{i+1}", value=item.get("subtitle", item.get("text", "")), key=f"tune_sub_{i}") + item["subtitle"] = st.text_input(f"字幕 #{i+1}", value=item.get("subtitle", item.get("text", "")), key=_ui_key(f"tune_sub_{i}")) with c3: # 兼容旧格式: 如果有 start_ratio 则转换为 start_time default_start = item.get("start_time", item.get("start_ratio", 0) * 12) - item["start_time"] = st.number_input(f"开始(秒) #{i+1}", value=float(default_start), min_value=0.0, max_value=30.0, step=0.5, key=f"tune_start_{i}") + item["start_time"] = st.number_input(f"开始(秒) #{i+1}", value=float(default_start), min_value=0.0, max_value=30.0, step=0.5, key=_ui_key(f"tune_start_{i}")) with c4: # 兼容旧格式: 如果有 duration_ratio 则转换为 duration default_dur = item.get("duration", item.get("duration_ratio", 0.25) * 12) - item["duration"] = st.number_input(f"时长(秒) #{i+1}", value=float(default_dur), min_value=0.5, max_value=15.0, step=0.5, key=f"tune_dur_{i}") + item["duration"] = st.number_input(f"时长(秒) #{i+1}", value=float(default_dur), min_value=0.5, max_value=15.0, step=0.5, key=_ui_key(f"tune_dur_{i}")) # 清理旧字段 item.pop("start_ratio", None) item.pop("duration_ratio", None) @@ -1145,7 +1162,7 @@ if st.session_state.view_mode == "workspace": st.markdown(f"**Scene {scene['id']}**") ft = scene.get("fancy_text", {}) ft_text = ft.get("text", "") if isinstance(ft, dict) else "" - new_ft = st.text_input(f"花字", value=ft_text, key=f"tune_ft_{i}") + new_ft = st.text_input(f"花字", value=ft_text, key=_ui_key(f"tune_ft_{i}")) # 兼容:旧数据可能没有 fancy_text 字段 if not isinstance(scene.get("fancy_text"), dict): scene["fancy_text"] = {} diff --git a/modules/image_gen.py b/modules/image_gen.py index 1f4e113..849f7ee 100644 --- a/modules/image_gen.py +++ b/modules/image_gen.py @@ -28,11 +28,12 @@ def _env_int(name: str, default: int) -> int: # Tunables: slow channels can be hot; default conservative but adjustable. -IMG_SUBMIT_TIMEOUT_S = _env_int("IMG_SUBMIT_TIMEOUT_S", 180) -IMG_POLL_TIMEOUT_S = _env_int("IMG_POLL_TIMEOUT_S", 30) -IMG_MAX_RETRIES = _env_int("IMG_MAX_RETRIES", 3) -IMG_POLL_INTERVAL_S = _env_int("IMG_POLL_INTERVAL_S", 2) -IMG_POLL_MAX_RETRIES = _env_int("IMG_POLL_MAX_RETRIES", 90) # 90*2s ~= 180s +# IMPORTANT: we enforce minimums to avoid accidental misconfig (e.g. 120s) causing flaky UX. +IMG_SUBMIT_TIMEOUT_S = max(_env_int("IMG_SUBMIT_TIMEOUT_S", 180), 180) +IMG_POLL_TIMEOUT_S = max(_env_int("IMG_POLL_TIMEOUT_S", 30), 10) +IMG_MAX_RETRIES = max(_env_int("IMG_MAX_RETRIES", 3), 3) +IMG_POLL_INTERVAL_S = max(_env_int("IMG_POLL_INTERVAL_S", 2), 1) +IMG_POLL_MAX_RETRIES = max(_env_int("IMG_POLL_MAX_RETRIES", 90), 90) # 90*2s ~= 180s def _is_retryable_exception(e: Exception) -> bool: @@ -43,6 +44,22 @@ def _is_retryable_exception(e: Exception) -> bool: # Transient provider errors often contain these keywords if any(k in msg for k in ["timeout", "temporarily", "temporarily unavailable", "gateway", "rate", "try again"]): return True + # Treat common HTTP transient status codes as retryable when they bubble up as RuntimeError text + # Examples from our code: "Shubiaobiao 提交失败 (429): ..." / "Doubao Image Failed (502): ..." + try: + import re + m = re.search(r"\((\d{3})\)", msg) + if not m: + # sometimes formatted like "429:" without parentheses + m = re.search(r"\b(\d{3})\b", msg) + if m: + code = int(m.group(1)) + if code in (408, 409, 425, 429, 500, 502, 503, 504): + return True + if 500 <= code <= 599: + return True + except Exception: + pass return False