fix(ui): widget key 按项目隔离,避免滚动卡死/加载串项目;fix(img): 最小180s+失败码重试3次

This commit is contained in:
Tony Zhang
2025-12-18 00:31:37 +08:00
parent c6436da17e
commit 3d1fb37769
2 changed files with 59 additions and 25 deletions

57
app.py
View File

@@ -133,6 +133,20 @@ if "view_mode" not in st.session_state:
st.session_state.view_mode = "workspace" # workspace, history, settings st.session_state.view_mode = "workspace" # workspace, history, settings
if "selected_img_provider" not in st.session_state: if "selected_img_provider" not in st.session_state:
st.session_state.selected_img_provider = "shubiaobiao" 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): def load_project(project_id):
"""Load project state from DB""" """Load project state from DB"""
@@ -142,6 +156,8 @@ def load_project(project_id):
return return
st.session_state.project_id = project_id 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.script_data = data.get("script_data")
st.session_state.view_mode = "workspace" st.session_state.view_mode = "workspace"
@@ -384,20 +400,21 @@ if st.session_state.view_mode == "workspace":
col1, col2 = st.columns([1, 1]) col1, col2 = st.columns([1, 1])
with col1: with col1:
product_name = st.text_input("商品标题", value=default_name) product_name = st.text_input("商品标题", value=default_name, key=_ui_key("product_name"))
category = st.text_input("商品类目", value=default_category) category = st.text_input("商品类目", value=default_category, key=_ui_key("category"))
price = st.text_input("价格", value=default_price) price = st.text_input("价格", value=default_price, key=_ui_key("price"))
with col2: with col2:
tags = st.text_area("评价标签 (用于提炼卖点)", value=default_tags, height=100) tags = st.text_area("评价标签 (用于提炼卖点)", value=default_tags, height=100, key=_ui_key("tags"))
params = st.text_area("商品参数", value=default_params, height=100) params = st.text_area("商品参数", value=default_params, height=100, key=_ui_key("params"))
# 商家自定义风格提示 # 商家自定义风格提示
style_hint = st.text_area( style_hint = st.text_area(
"商品视频重点增强提示 (可选)", "商品视频重点增强提示 (可选)",
value=loaded_info.get("style_hint", ""), value=loaded_info.get("style_hint", ""),
placeholder="例如:韩风、高级感、活力青春、简约日系...", placeholder="例如:韩风、高级感、活力青春、简约日系...",
height=80 height=80,
key=_ui_key("style_hint"),
) )
st.markdown("### 上传素材") st.markdown("### 上传素材")
@@ -418,7 +435,7 @@ if st.session_state.view_mode == "workspace":
# Model Selection (all support images; user explicitly chooses model) # Model Selection (all support images; user explicitly chooses model)
model_options = ["GPT-5.2", "Gemini 3 Pro", "Doubao Pro (Vision)"] 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 # Map label to provider key
if selected_model_label == "GPT-5.2": if selected_model_label == "GPT-5.2":
model_provider = "shubiaobiao_gpt" model_provider = "shubiaobiao_gpt"
@@ -548,17 +565,17 @@ if st.session_state.view_mode == "workspace":
for i, item in enumerate(timeline): for i, item in enumerate(timeline):
c1, c2, c3, c4 = st.columns([3, 3, 1, 1]) c1, c2, c3, c4 = st.columns([3, 3, 1, 1])
with c1: 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: 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: with c3:
# 兼容旧格式: 如果有 start_ratio 则转换为 start_time # 兼容旧格式: 如果有 start_ratio 则转换为 start_time
default_start = item.get("start_time", item.get("start_ratio", 0) * 12) # 假设总时长12秒 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: with c4:
# 兼容旧格式: 如果有 duration_ratio 则转换为 duration # 兼容旧格式: 如果有 duration_ratio 则转换为 duration
default_dur = item.get("duration", item.get("duration_ratio", 0.25) * 12) # 假设总时长12秒 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("start_ratio", None)
item.pop("duration_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]) c_vis, c_aud = st.columns([2, 1])
with c_vis: 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_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=f"vidp_{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["visual_prompt"] = new_visual
scene["video_prompt"] = new_video scene["video_prompt"] = new_video
@@ -589,7 +606,7 @@ if st.session_state.view_mode == "workspace":
new_ft_text = st.text_input( new_ft_text = st.text_input(
f"Fancy Text (Scene {scene['id']})", f"Fancy Text (Scene {scene['id']})",
value=ft.get("text", ""), value=ft.get("text", ""),
key=f"ft_{i}", key=_ui_key(f"ft_{i}"),
) )
# 兼容:旧数据可能没有 fancy_text 字段 # 兼容:旧数据可能没有 fancy_text 字段
if not isinstance(scene.get("fancy_text"), dict): 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: if not st.session_state.scene_images:
# Model Selection for Image Gen # Model Selection for Image Gen
img_model_options = ["Shubiaobiao (Gemini)", "Doubao (Volcengine)", "Gemini (Direct)", "Doubao (Group Image)"] 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 # Map to provider
if "Group Image" in selected_img_model: if "Group Image" in selected_img_model:
@@ -1116,17 +1133,17 @@ if st.session_state.view_mode == "workspace":
for i, item in enumerate(timeline): for i, item in enumerate(timeline):
c1, c2, c3, c4 = st.columns([3, 3, 1, 1]) c1, c2, c3, c4 = st.columns([3, 3, 1, 1])
with c1: 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: 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: with c3:
# 兼容旧格式: 如果有 start_ratio 则转换为 start_time # 兼容旧格式: 如果有 start_ratio 则转换为 start_time
default_start = item.get("start_time", item.get("start_ratio", 0) * 12) 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: with c4:
# 兼容旧格式: 如果有 duration_ratio 则转换为 duration # 兼容旧格式: 如果有 duration_ratio 则转换为 duration
default_dur = item.get("duration", item.get("duration_ratio", 0.25) * 12) 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("start_ratio", None)
item.pop("duration_ratio", None) item.pop("duration_ratio", None)
@@ -1145,7 +1162,7 @@ if st.session_state.view_mode == "workspace":
st.markdown(f"**Scene {scene['id']}**") st.markdown(f"**Scene {scene['id']}**")
ft = scene.get("fancy_text", {}) ft = scene.get("fancy_text", {})
ft_text = ft.get("text", "") if isinstance(ft, dict) else "" 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 字段 # 兼容:旧数据可能没有 fancy_text 字段
if not isinstance(scene.get("fancy_text"), dict): if not isinstance(scene.get("fancy_text"), dict):
scene["fancy_text"] = {} scene["fancy_text"] = {}

View File

@@ -28,11 +28,12 @@ def _env_int(name: str, default: int) -> int:
# Tunables: slow channels can be hot; default conservative but adjustable. # Tunables: slow channels can be hot; default conservative but adjustable.
IMG_SUBMIT_TIMEOUT_S = _env_int("IMG_SUBMIT_TIMEOUT_S", 180) # IMPORTANT: we enforce minimums to avoid accidental misconfig (e.g. 120s) causing flaky UX.
IMG_POLL_TIMEOUT_S = _env_int("IMG_POLL_TIMEOUT_S", 30) IMG_SUBMIT_TIMEOUT_S = max(_env_int("IMG_SUBMIT_TIMEOUT_S", 180), 180)
IMG_MAX_RETRIES = _env_int("IMG_MAX_RETRIES", 3) IMG_POLL_TIMEOUT_S = max(_env_int("IMG_POLL_TIMEOUT_S", 30), 10)
IMG_POLL_INTERVAL_S = _env_int("IMG_POLL_INTERVAL_S", 2) IMG_MAX_RETRIES = max(_env_int("IMG_MAX_RETRIES", 3), 3)
IMG_POLL_MAX_RETRIES = _env_int("IMG_POLL_MAX_RETRIES", 90) # 90*2s ~= 180s 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: 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 # Transient provider errors often contain these keywords
if any(k in msg for k in ["timeout", "temporarily", "temporarily unavailable", "gateway", "rate", "try again"]): if any(k in msg for k in ["timeout", "temporarily", "temporarily unavailable", "gateway", "rate", "try again"]):
return True 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 return False