fix(ui): widget key 按项目隔离,避免滚动卡死/加载串项目;fix(img): 最小180s+失败码重试3次
This commit is contained in:
57
app.py
57
app.py
@@ -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"] = {}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user