perf(8502): 并行生图(6并发)+超时重试;视频URL直连预览/下载;路径隔离
This commit is contained in:
502
app.py
502
app.py
@@ -9,6 +9,8 @@ import os
|
||||
import random
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
from time import perf_counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
# Import Backend Modules
|
||||
import config
|
||||
@@ -19,6 +21,10 @@ from modules.composer import VideoComposer, VideoComposer as Composer # alias
|
||||
from modules.text_renderer import renderer
|
||||
from modules import export_utils
|
||||
from modules.db_manager import db
|
||||
from modules import path_utils
|
||||
from modules import limits
|
||||
from modules.legacy_path_mapper import map_legacy_local_path
|
||||
from modules.legacy_normalizer import normalize_legacy_project
|
||||
|
||||
# Page Config
|
||||
st.set_page_config(
|
||||
@@ -137,6 +143,32 @@ def load_project(project_id):
|
||||
st.session_state.project_id = project_id
|
||||
st.session_state.script_data = data.get("script_data")
|
||||
st.session_state.view_mode = "workspace"
|
||||
|
||||
# Fallback: 如果 DB 中的 script_data 是旧结构/缺字段,则从 legacy JSON 重新规范化一次
|
||||
try:
|
||||
script_data = st.session_state.script_data
|
||||
legacy_json = Path(config.TEMP_DIR) / f"project_{project_id}.json"
|
||||
|
||||
def _needs_normalize(sd: Any) -> bool:
|
||||
if not isinstance(sd, dict):
|
||||
return True
|
||||
if "_legacy_schema" not in sd:
|
||||
return True
|
||||
scenes = sd.get("scenes") or []
|
||||
if scenes and isinstance(scenes, list) and isinstance(scenes[0], dict):
|
||||
if "visual_prompt" not in scenes[0] or "video_prompt" not in scenes[0]:
|
||||
return True
|
||||
return False
|
||||
|
||||
if legacy_json.exists() and _needs_normalize(script_data):
|
||||
raw = json.loads(legacy_json.read_text(encoding="utf-8"))
|
||||
normalized = normalize_legacy_project(raw)
|
||||
st.session_state.script_data = normalized
|
||||
# 写回 DB,避免每次 load 都重新算
|
||||
db.update_project_script(project_id, normalized)
|
||||
st.info("已从 legacy JSON 重新规范化脚本字段(兼容旧版项目)。")
|
||||
except Exception as e:
|
||||
st.warning(f"legacy 规范化失败(将继续使用 DB 数据): {e}")
|
||||
|
||||
# Restore product info for Step 1 display
|
||||
product_info = data.get("product_info", {})
|
||||
@@ -161,13 +193,17 @@ def load_project(project_id):
|
||||
|
||||
for asset in assets:
|
||||
sid = asset["scene_id"]
|
||||
source_path, _mapped_url = map_legacy_local_path(asset.get("local_path"))
|
||||
# 假设 scene_id 0 或 -1 用于 final video
|
||||
if asset["asset_type"] == "image" and asset["status"] == "completed":
|
||||
images[sid] = asset["local_path"]
|
||||
if source_path:
|
||||
images[sid] = source_path
|
||||
elif asset["asset_type"] == "video" and asset["status"] == "completed":
|
||||
videos[sid] = asset["local_path"]
|
||||
if source_path:
|
||||
videos[sid] = source_path
|
||||
elif asset["asset_type"] == "final_video" and asset["status"] == "completed":
|
||||
final_vid = asset["local_path"]
|
||||
if source_path:
|
||||
final_vid = source_path
|
||||
|
||||
st.session_state.scene_images = images
|
||||
st.session_state.scene_videos = videos
|
||||
@@ -233,6 +269,33 @@ with st.sidebar:
|
||||
|
||||
if st.session_state.project_id:
|
||||
st.caption(f"Current ID: {st.session_state.project_id}")
|
||||
with st.expander("⏱️ 性能与诊断", expanded=False):
|
||||
m = _get_metrics(st.session_state.project_id)
|
||||
if not m:
|
||||
st.caption("暂无指标(执行一次脚本/生图/生视频/合成后会出现)。")
|
||||
else:
|
||||
keys = [
|
||||
"script_gen_s",
|
||||
"image_gen_total_s",
|
||||
"video_submit_s",
|
||||
"video_recover_s",
|
||||
"compose_s",
|
||||
"script_model",
|
||||
"image_provider",
|
||||
"image_generated",
|
||||
"video_submitted",
|
||||
"video_recovered",
|
||||
"bgm_used",
|
||||
]
|
||||
for k in keys:
|
||||
if k in m:
|
||||
st.caption(f"{k}: {m.get(k)}")
|
||||
# 在线剪辑入口(React Editor)
|
||||
web_base_url = os.getenv("WEB_BASE_URL", "http://localhost:3000").rstrip("/")
|
||||
st.markdown(
|
||||
f"[打开在线剪辑器]({web_base_url}/editor/{st.session_state.project_id})",
|
||||
unsafe_allow_html=False,
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
@@ -258,14 +321,48 @@ with st.sidebar:
|
||||
# ============================================================
|
||||
# Helper Functions
|
||||
# ============================================================
|
||||
def save_uploaded_file(uploaded_file):
|
||||
"""Save uploaded file to temp dir."""
|
||||
if uploaded_file is not None:
|
||||
file_path = config.TEMP_DIR / uploaded_file.name
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(uploaded_file.getbuffer())
|
||||
return str(file_path)
|
||||
return None
|
||||
def _record_metrics(project_id: str, patch: dict):
|
||||
"""Persist lightweight timing/diagnostic metrics into project.product_info['_metrics']."""
|
||||
if not project_id or not isinstance(patch, dict) or not patch:
|
||||
return
|
||||
try:
|
||||
proj = db.get_project(project_id) or {}
|
||||
product_info = proj.get("product_info") or {}
|
||||
metrics = product_info.get("_metrics") if isinstance(product_info.get("_metrics"), dict) else {}
|
||||
metrics.update(patch)
|
||||
metrics["updated_at"] = time.time()
|
||||
product_info["_metrics"] = metrics
|
||||
db.update_project_product_info(project_id, product_info)
|
||||
except Exception:
|
||||
# metrics must never break UX
|
||||
pass
|
||||
|
||||
|
||||
def _get_metrics(project_id: str) -> dict:
|
||||
try:
|
||||
proj = db.get_project(project_id) or {}
|
||||
product_info = proj.get("product_info") or {}
|
||||
m = product_info.get("_metrics")
|
||||
return m if isinstance(m, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def save_uploaded_file(project_id: str, uploaded_file):
|
||||
"""Save uploaded file to per-project upload dir (avoid overwrites across projects)."""
|
||||
if uploaded_file is None:
|
||||
return None
|
||||
if not project_id:
|
||||
raise ValueError("project_id is required to save uploaded files safely")
|
||||
upload_dir = path_utils.project_upload_dir(project_id)
|
||||
original = path_utils.sanitize_filename(getattr(uploaded_file, "name", "upload"))
|
||||
# keep original stem for readability, but ensure uniqueness
|
||||
suffix = Path(original).suffix.lstrip(".") or "bin"
|
||||
stem = Path(original).stem or "upload"
|
||||
unique_name = path_utils.unique_filename(prefix=f"upload_{stem}", ext=suffix, project_id=project_id)
|
||||
file_path = upload_dir / unique_name
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(uploaded_file.getbuffer())
|
||||
return str(file_path)
|
||||
|
||||
# ============================================================
|
||||
# Main Content: Workspace
|
||||
@@ -318,11 +415,16 @@ if st.session_state.view_mode == "workspace":
|
||||
# 允许在没有上传新图片但有历史图片的情况下继续
|
||||
can_submit = uploaded_files or st.session_state.uploaded_images
|
||||
|
||||
# Model Selection
|
||||
model_options = ["Gemini 3 Pro", "Doubao Pro (Vision)"]
|
||||
selected_model_label = st.radio("选择脚本生成模型", model_options, horizontal=True)
|
||||
# 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)
|
||||
# Map label to provider key
|
||||
model_provider = "doubao" if "Doubao" in selected_model_label else "shubiaobiao"
|
||||
if selected_model_label == "GPT-5.2":
|
||||
model_provider = "shubiaobiao_gpt"
|
||||
elif "Doubao" in selected_model_label:
|
||||
model_provider = "doubao"
|
||||
else:
|
||||
model_provider = "shubiaobiao"
|
||||
|
||||
if st.button("提交任务 & 生成脚本", type="primary", disabled=(not can_submit)):
|
||||
# 处理图片路径
|
||||
@@ -335,7 +437,7 @@ if st.session_state.view_mode == "workspace":
|
||||
st.session_state.project_id = f"PROJ-{int(time.time())}"
|
||||
|
||||
for uf in uploaded_files:
|
||||
path = save_uploaded_file(uf)
|
||||
path = save_uploaded_file(st.session_state.project_id, uf)
|
||||
if path: image_paths.append(path)
|
||||
|
||||
st.session_state.uploaded_images = image_paths
|
||||
@@ -352,7 +454,12 @@ if st.session_state.view_mode == "workspace":
|
||||
# Call Script Generator
|
||||
with st.spinner(f"正在分析商品信息并生成脚本 ({selected_model_label})..."):
|
||||
gen = ScriptGenerator()
|
||||
t0 = perf_counter()
|
||||
script = gen.generate_script(product_name, product_info, image_paths, model_provider=model_provider)
|
||||
_record_metrics(st.session_state.project_id, {
|
||||
"script_gen_s": round(perf_counter() - t0, 3),
|
||||
"script_model": model_provider,
|
||||
})
|
||||
|
||||
if script:
|
||||
st.session_state.script_data = script
|
||||
@@ -371,10 +478,39 @@ if st.session_state.view_mode == "workspace":
|
||||
if st.session_state.script_data:
|
||||
script = st.session_state.script_data
|
||||
|
||||
# Display Basic Info
|
||||
# Display Basic Info (兼容 legacy schema)
|
||||
selling_points = script.get("selling_points", []) or []
|
||||
target_audience = script.get("target_audience", "") or ""
|
||||
analysis_text = script.get("analysis", "") or ""
|
||||
legacy_schema = script.get("_legacy_schema", "") or ""
|
||||
|
||||
c1, c2 = st.columns(2)
|
||||
c1.write(f"**核心卖点**: {', '.join(script.get('selling_points', []))}")
|
||||
c2.write(f"**目标人群**: {script.get('target_audience', '')}")
|
||||
if selling_points:
|
||||
c1.write(f"**核心卖点**: {', '.join(selling_points)}")
|
||||
else:
|
||||
c1.write("**核心卖点**: (legacy 项目可能未生成该字段)")
|
||||
if analysis_text:
|
||||
with st.expander("查看 legacy analysis(用于补齐信息)"):
|
||||
st.write(analysis_text)
|
||||
|
||||
if target_audience:
|
||||
c2.write(f"**目标人群**: {target_audience}")
|
||||
else:
|
||||
c2.write("**目标人群**: (legacy 项目可能未生成该字段)")
|
||||
|
||||
# Hook / CTA / Schema
|
||||
hook = script.get("hook", "") or ""
|
||||
if hook:
|
||||
st.markdown(f"**Hook**: {hook}")
|
||||
cta = script.get("cta", "")
|
||||
if cta:
|
||||
if isinstance(cta, dict):
|
||||
st.markdown("**CTA(legacy object)**")
|
||||
st.json(cta)
|
||||
else:
|
||||
st.markdown(f"**CTA**: {cta}")
|
||||
if legacy_schema:
|
||||
st.caption(f"Legacy Schema: {legacy_schema}")
|
||||
|
||||
# Prompt Visualization
|
||||
if "_debug" in script:
|
||||
@@ -397,10 +533,15 @@ if st.session_state.view_mode == "workspace":
|
||||
# Global Voiceover Timeline (New)
|
||||
st.markdown("### 🎙️ 整体旁白与字幕时间轴")
|
||||
with st.expander("编辑旁白时间轴 (Voiceover Timeline)", expanded=True):
|
||||
timeline = script.get("voiceover_timeline", [])
|
||||
timeline = script.get("voiceover_timeline", []) or []
|
||||
if not timeline:
|
||||
# Init with default if empty (使用秒)
|
||||
timeline = [{"text": "示例旁白", "subtitle": "示例字幕", "start_time": 0.0, "duration": 3.0}]
|
||||
# 对于历史项目:如果没有 scenes 也没有 timeline,不要强行塞“示例旁白”,避免污染数据
|
||||
if not scenes and analysis_text:
|
||||
st.info("该历史项目暂无旁白时间轴(可能停留在分析/提问阶段)。")
|
||||
timeline = []
|
||||
else:
|
||||
# Init with default if empty (使用秒)
|
||||
timeline = [{"text": "示例旁白", "subtitle": "示例字幕", "start_time": 0.0, "duration": 3.0}]
|
||||
|
||||
updated_timeline = []
|
||||
for i, item in enumerate(timeline):
|
||||
@@ -444,11 +585,40 @@ if st.session_state.view_mode == "workspace":
|
||||
# 花字编辑保留
|
||||
ft = scene.get("fancy_text", {})
|
||||
if isinstance(ft, dict):
|
||||
new_ft_text = st.text_input(f"Fancy Text (Scene {scene['id']})", value=ft.get("text", ""), key=f"ft_{i}")
|
||||
new_ft_text = st.text_input(
|
||||
f"Fancy Text (Scene {scene['id']})",
|
||||
value=ft.get("text", ""),
|
||||
key=f"ft_{i}",
|
||||
)
|
||||
# 兼容:旧数据可能没有 fancy_text 字段
|
||||
if not isinstance(scene.get("fancy_text"), dict):
|
||||
scene["fancy_text"] = {}
|
||||
scene["fancy_text"]["text"] = new_ft_text
|
||||
|
||||
# 旁白/字幕已移至上方整体时间轴,此处仅作展示或删除
|
||||
st.caption("注:旁白与字幕已移至上方整体时间轴编辑")
|
||||
|
||||
# Legacy 信息展示(只读,用于调试/对齐)
|
||||
legacy_scene = scene.get("_legacy", {}) if isinstance(scene.get("_legacy", {}), dict) else {}
|
||||
if legacy_scene:
|
||||
with st.expander(f"Legacy 信息 (Scene {scene['id']})", expanded=False):
|
||||
img_url = legacy_scene.get("image_url")
|
||||
if img_url:
|
||||
st.markdown(f"- image_url: `{img_url}`")
|
||||
cam = legacy_scene.get("camera_movement")
|
||||
if cam:
|
||||
st.markdown(f"- camera_movement: {cam}")
|
||||
vo = legacy_scene.get("voiceover")
|
||||
if vo:
|
||||
st.markdown(f"- voiceover: {vo}")
|
||||
keyframe = legacy_scene.get("keyframe")
|
||||
if keyframe:
|
||||
st.markdown("- keyframe:")
|
||||
st.json(keyframe)
|
||||
rhythm = legacy_scene.get("rhythm")
|
||||
if rhythm:
|
||||
st.markdown("- rhythm:")
|
||||
st.json(rhythm)
|
||||
|
||||
updated_scenes.append(scene)
|
||||
st.divider()
|
||||
@@ -497,9 +667,13 @@ if st.session_state.view_mode == "workspace":
|
||||
st.session_state.selected_img_provider = img_provider
|
||||
|
||||
if st.button("🚀 执行 AI 生图", type="primary"):
|
||||
img_gen = ImageGenerator()
|
||||
# Pass ALL uploaded images as reference
|
||||
base_imgs = st.session_state.uploaded_images if st.session_state.uploaded_images else []
|
||||
with limits.acquire_image(blocking=False) as ok:
|
||||
if not ok:
|
||||
st.warning("系统正在生成其他任务(生图并发已达上限),请稍后再试。")
|
||||
st.stop()
|
||||
img_gen = ImageGenerator()
|
||||
# Pass ALL uploaded images as reference
|
||||
base_imgs = st.session_state.uploaded_images if st.session_state.uploaded_images else []
|
||||
|
||||
if not base_imgs:
|
||||
st.error("No base image found (未找到参考底图). Please upload in Step 1.")
|
||||
@@ -514,11 +688,18 @@ if st.session_state.view_mode == "workspace":
|
||||
# --- Group Generation Logic ---
|
||||
with st.spinner("正在进行 Doubao 组图生成 (Batch Group Generation)..."):
|
||||
try:
|
||||
t0 = perf_counter()
|
||||
results = img_gen.generate_group_images_doubao(
|
||||
scenes=scenes,
|
||||
reference_images=base_imgs,
|
||||
visual_anchor=visual_anchor
|
||||
visual_anchor=visual_anchor,
|
||||
project_id=st.session_state.project_id
|
||||
)
|
||||
_record_metrics(st.session_state.project_id, {
|
||||
"image_gen_total_s": round(perf_counter() - t0, 3),
|
||||
"image_provider": img_provider,
|
||||
"image_generated": len(results),
|
||||
})
|
||||
|
||||
for s_id, path in results.items():
|
||||
st.session_state.scene_images[s_id] = path
|
||||
@@ -536,35 +717,53 @@ if st.session_state.view_mode == "workspace":
|
||||
except Exception as e:
|
||||
st.error(f"组图生成失败: {e}")
|
||||
else:
|
||||
# --- Sequential Logic ---
|
||||
# --- Parallel Logic (default): only merchant uploaded images as references ---
|
||||
total_scenes = len(scenes)
|
||||
progress_bar = st.progress(0)
|
||||
status_text = st.empty()
|
||||
|
||||
current_refs = list(base_imgs) # Start with base images
|
||||
|
||||
|
||||
try:
|
||||
for idx, scene in enumerate(scenes):
|
||||
scene_id = scene["id"]
|
||||
status_text.text(f"正在生成 Scene {scene_id} ({idx+1}/{total_scenes}) using {selected_img_model}...")
|
||||
|
||||
img_path = img_gen.generate_single_scene_image(
|
||||
scene=scene,
|
||||
original_image_path=current_refs, # Pass ALL accumulated images
|
||||
previous_image_path=None,
|
||||
model_provider=img_provider,
|
||||
visual_anchor=visual_anchor
|
||||
)
|
||||
|
||||
if img_path:
|
||||
st.session_state.scene_images[scene_id] = img_path
|
||||
current_refs.append(img_path) # Add newly generated image to references for next scene
|
||||
db.save_asset(st.session_state.project_id, scene_id, "image", "completed", local_path=img_path)
|
||||
|
||||
progress_bar.progress((idx + 1) / total_scenes)
|
||||
t0 = perf_counter()
|
||||
# Parallel workers within a single run; global semaphore already acquired above.
|
||||
max_workers = 6
|
||||
futures = {}
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as ex:
|
||||
for idx, scene in enumerate(scenes):
|
||||
scene_id = scene["id"]
|
||||
futures[ex.submit(
|
||||
img_gen.generate_single_scene_image,
|
||||
scene=scene,
|
||||
original_image_path=list(base_imgs), # ONLY merchant images
|
||||
previous_image_path=None,
|
||||
model_provider=img_provider,
|
||||
visual_anchor=visual_anchor,
|
||||
project_id=st.session_state.project_id,
|
||||
)] = (idx, scene_id)
|
||||
|
||||
done = 0
|
||||
for fut in as_completed(futures):
|
||||
idx, scene_id = futures[fut]
|
||||
done += 1
|
||||
status_text.text(f"已完成 {done}/{total_scenes}(Scene {scene_id})")
|
||||
try:
|
||||
img_path = fut.result()
|
||||
except Exception as e:
|
||||
img_path = None
|
||||
st.warning(f"Scene {scene_id} 生成失败:{e}")
|
||||
|
||||
if img_path:
|
||||
st.session_state.scene_images[scene_id] = img_path
|
||||
db.save_asset(st.session_state.project_id, scene_id, "image", "completed", local_path=img_path)
|
||||
|
||||
progress_bar.progress(done / total_scenes)
|
||||
|
||||
status_text.text("生图完成!")
|
||||
st.success("生图完成!")
|
||||
_record_metrics(st.session_state.project_id, {
|
||||
"image_gen_total_s": round(perf_counter() - t0, 3),
|
||||
"image_provider": img_provider,
|
||||
"image_generated": len(st.session_state.scene_images),
|
||||
})
|
||||
# Update Status
|
||||
db.update_project_status(st.session_state.project_id, "images_generated")
|
||||
time.sleep(1)
|
||||
@@ -603,11 +802,8 @@ if st.session_state.view_mode == "workspace":
|
||||
else:
|
||||
with st.spinner(f"正在重绘 Scene {scene_id}..."):
|
||||
img_gen = ImageGenerator()
|
||||
# Use ALL uploaded images + previously generated images up to this point
|
||||
# Only merchant uploaded images as references (no chaining)
|
||||
current_refs_for_regen = list(st.session_state.uploaded_images)
|
||||
for prev_s_id in range(1, scene_id):
|
||||
if prev_s_id in st.session_state.scene_images:
|
||||
current_refs_for_regen.append(st.session_state.scene_images[prev_s_id])
|
||||
|
||||
# Fallback to single mode for regen if group was used
|
||||
provider = st.session_state.get("selected_img_provider", "shubiaobiao")
|
||||
@@ -621,7 +817,8 @@ if st.session_state.view_mode == "workspace":
|
||||
original_image_path=current_refs_for_regen,
|
||||
previous_image_path=None,
|
||||
model_provider=provider,
|
||||
visual_anchor=regen_visual_anchor
|
||||
visual_anchor=regen_visual_anchor,
|
||||
project_id=st.session_state.project_id
|
||||
)
|
||||
if new_path:
|
||||
st.session_state.scene_images[scene_id] = new_path
|
||||
@@ -636,33 +833,119 @@ if st.session_state.view_mode == "workspace":
|
||||
if st.session_state.current_step >= 3:
|
||||
with st.expander("🎥 4. 视频生成 (Volcengine I2V)", expanded=(st.session_state.current_step == 3)):
|
||||
|
||||
if not st.session_state.scene_videos:
|
||||
if st.button("🎬 执行图生视频", type="primary"):
|
||||
with st.spinner("正在生成视频 (耗时较长)..."):
|
||||
vid_gen = VideoGenerator()
|
||||
# Pass project_id
|
||||
videos = vid_gen.generate_scene_videos(
|
||||
st.session_state.project_id,
|
||||
st.session_state.script_data,
|
||||
st.session_state.scene_images
|
||||
scenes = st.session_state.script_data.get("scenes", [])
|
||||
vid_gen = VideoGenerator()
|
||||
|
||||
# Submit-only (non-blocking) to avoid freezing Streamlit under concurrency
|
||||
if st.button("🎬 提交图生视频任务(非阻塞)", type="primary"):
|
||||
with limits.acquire_video(blocking=False) as ok:
|
||||
if not ok:
|
||||
st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。")
|
||||
st.stop()
|
||||
t0 = perf_counter()
|
||||
submitted = 0
|
||||
for scene in scenes:
|
||||
scene_id = scene["id"]
|
||||
image_path = st.session_state.scene_images.get(scene_id)
|
||||
prompt = scene.get("video_prompt", "High quality video")
|
||||
task_id = vid_gen.submit_scene_video_task(
|
||||
st.session_state.project_id, scene_id, image_path, prompt
|
||||
)
|
||||
|
||||
if videos:
|
||||
st.session_state.scene_videos = videos
|
||||
for sid, path in videos.items():
|
||||
db.save_asset(st.session_state.project_id, sid, "video", "completed", local_path=path)
|
||||
|
||||
# Update Status
|
||||
db.update_project_status(st.session_state.project_id, "videos_generated")
|
||||
st.success("视频生成完成!")
|
||||
st.rerun()
|
||||
else:
|
||||
st.warning("部分或全部视频生成失败")
|
||||
if task_id:
|
||||
submitted += 1
|
||||
_record_metrics(st.session_state.project_id, {
|
||||
"video_submit_s": round(perf_counter() - t0, 3),
|
||||
"video_submitted": submitted,
|
||||
})
|
||||
if submitted:
|
||||
db.update_project_status(st.session_state.project_id, "videos_processing")
|
||||
st.success(f"已提交 {submitted} 个分镜视频任务。可点击下方“刷新恢复”下载结果。")
|
||||
time.sleep(0.5)
|
||||
st.rerun()
|
||||
else:
|
||||
st.warning("未提交任何任务(可能缺少图片或接口失败)。")
|
||||
|
||||
if st.button("🔄 刷新状态并恢复已完成任务", type="secondary"):
|
||||
with limits.acquire_video(blocking=False) as ok:
|
||||
if not ok:
|
||||
st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。")
|
||||
st.stop()
|
||||
t0 = perf_counter()
|
||||
updated = 0
|
||||
for scene in scenes:
|
||||
scene_id = scene["id"]
|
||||
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
|
||||
if not asset or not asset.get("task_id"):
|
||||
continue
|
||||
# if already have local video, skip
|
||||
existing = st.session_state.scene_videos.get(scene_id)
|
||||
if existing and os.path.exists(existing):
|
||||
continue
|
||||
task_id = asset.get("task_id")
|
||||
# Query volc status; store URL for direct preview (no server download)
|
||||
status = None
|
||||
url = None
|
||||
# short retries for "succeeded but url missing"
|
||||
for attempt in range(3):
|
||||
status, url = vid_gen.check_task_status(task_id)
|
||||
if status == "succeeded" and url:
|
||||
break
|
||||
time.sleep(0.5 * (2 ** attempt))
|
||||
|
||||
meta_patch = {"checked_at": time.time(), "volc_status": status}
|
||||
if url:
|
||||
meta_patch["video_url"] = url
|
||||
db.update_asset_metadata(st.session_state.project_id, scene_id, "video", meta_patch)
|
||||
updated += 1
|
||||
|
||||
_record_metrics(st.session_state.project_id, {
|
||||
"video_recover_s": round(perf_counter() - t0, 3),
|
||||
"video_recovered": updated,
|
||||
})
|
||||
if updated:
|
||||
st.success(f"已刷新 {updated} 个分镜状态(成功的将以 URL 直连预览)。")
|
||||
else:
|
||||
st.info("暂无可恢复的视频(可能仍在排队/生成中)。")
|
||||
time.sleep(0.5)
|
||||
st.rerun()
|
||||
|
||||
if st.button("📥 准备合成素材(下载成功的视频到服务器)", type="secondary"):
|
||||
with limits.acquire_video(blocking=False) as ok:
|
||||
if not ok:
|
||||
st.warning("系统正在处理其他视频任务(并发已达上限),请稍后再试。")
|
||||
st.stop()
|
||||
downloaded = 0
|
||||
for scene in scenes:
|
||||
scene_id = scene["id"]
|
||||
existing = st.session_state.scene_videos.get(scene_id)
|
||||
if existing and os.path.exists(existing):
|
||||
continue
|
||||
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
|
||||
meta = (asset or {}).get("metadata") or {}
|
||||
video_url = meta.get("video_url")
|
||||
if not video_url:
|
||||
continue
|
||||
out_name = path_utils.unique_filename(
|
||||
prefix="scene_video",
|
||||
ext="mp4",
|
||||
project_id=st.session_state.project_id,
|
||||
scene_id=scene_id,
|
||||
)
|
||||
target_path = str(path_utils.project_videos_dir(st.session_state.project_id) / out_name)
|
||||
if vid_gen._download_video_to(video_url, target_path):
|
||||
st.session_state.scene_videos[scene_id] = target_path
|
||||
db.save_asset(st.session_state.project_id, scene_id, "video", "completed", local_path=target_path, task_id=(asset or {}).get("task_id"), metadata=meta)
|
||||
downloaded += 1
|
||||
if downloaded:
|
||||
st.success(f"已下载 {downloaded} 段视频,可进入合成。")
|
||||
else:
|
||||
st.info("暂无可下载的视频(请先刷新状态获取 video_url)。")
|
||||
time.sleep(0.5)
|
||||
st.rerun()
|
||||
|
||||
# Display Videos
|
||||
if st.session_state.scene_videos:
|
||||
# Display Videos (even when partially available)
|
||||
if st.session_state.scene_videos or scenes:
|
||||
cols = st.columns(4)
|
||||
scenes = st.session_state.script_data.get("scenes", [])
|
||||
|
||||
for i, scene in enumerate(scenes):
|
||||
scene_id = scene["id"]
|
||||
@@ -677,24 +960,27 @@ if st.session_state.view_mode == "workspace":
|
||||
if vid_path and os.path.exists(vid_path):
|
||||
st.video(vid_path)
|
||||
else:
|
||||
st.warning("Video missing")
|
||||
# --- Recovery Logic ---
|
||||
# Try URL preview from DB metadata
|
||||
asset = db.get_asset(st.session_state.project_id, scene_id, "video")
|
||||
meta = (asset or {}).get("metadata") or {}
|
||||
video_url = meta.get("video_url")
|
||||
if video_url:
|
||||
st.caption("URL 直连预览(不经服务器落盘)")
|
||||
st.video(video_url)
|
||||
else:
|
||||
st.warning("Video missing")
|
||||
# --- Recovery Logic ---
|
||||
if asset and asset.get("task_id"):
|
||||
task_id = asset.get("task_id")
|
||||
if st.button(f"🔍 找回视频 (Task {task_id[-6:]})", key=f"recov_{scene_id}"):
|
||||
if st.button(f"🔍 刷新URL (Task {task_id[-6:]})", key=f"recov_{scene_id}"):
|
||||
with st.spinner("查询任务状态中..."):
|
||||
vid_gen = VideoGenerator()
|
||||
output_filename = f"scene_{scene_id}_video.mp4"
|
||||
target_path = str(config.TEMP_DIR / output_filename)
|
||||
|
||||
if vid_gen.recover_video_from_task(task_id, target_path):
|
||||
st.session_state.scene_videos[scene_id] = target_path
|
||||
db.save_asset(st.session_state.project_id, scene_id, "video", "completed", local_path=target_path)
|
||||
st.success("找回成功!")
|
||||
st.rerun()
|
||||
else:
|
||||
st.error("找回失败")
|
||||
status, url = vid_gen.check_task_status(task_id)
|
||||
patch = {"checked_at": time.time(), "volc_status": status}
|
||||
if url:
|
||||
patch["video_url"] = url
|
||||
db.update_asset_metadata(st.session_state.project_id, scene_id, "video", patch)
|
||||
st.success("已刷新任务状态。")
|
||||
st.rerun()
|
||||
|
||||
# Per-scene regenerate button
|
||||
if st.button(f"🔄 重生 S{scene_id}", key=f"regen_vid_{scene_id}"):
|
||||
@@ -769,6 +1055,13 @@ if st.session_state.view_mode == "workspace":
|
||||
["None"] + bgm_names,
|
||||
index=default_idx
|
||||
)
|
||||
# 明确提示:BGM 目录为空或选中 BGM 不存在时,本次将不含 BGM
|
||||
if not bgm_names:
|
||||
st.warning(f"BGM 目录为空:{bgm_dir}(本次合成将不含 BGM)")
|
||||
elif selected_bgm != "None":
|
||||
candidate = config.ASSETS_DIR / "bgm" / selected_bgm
|
||||
if not candidate.exists():
|
||||
st.warning(f"所选 BGM 文件不存在:{candidate}(本次合成将不含 BGM)")
|
||||
with col_g2:
|
||||
# Voice Select
|
||||
selected_voice = st.selectbox("配音音色 (TTS)", [config.VOLC_TTS_DEFAULT_VOICE, "zh_female_meilinvyou_saturn_bigtts"])
|
||||
@@ -812,8 +1105,10 @@ if st.session_state.view_mode == "workspace":
|
||||
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}")
|
||||
if isinstance(scene.get("fancy_text"), dict):
|
||||
scene["fancy_text"]["text"] = new_ft
|
||||
# 兼容:旧数据可能没有 fancy_text 字段
|
||||
if not isinstance(scene.get("fancy_text"), dict):
|
||||
scene["fancy_text"] = {}
|
||||
scene["fancy_text"]["text"] = new_ft
|
||||
|
||||
updated_scenes.append(scene)
|
||||
|
||||
@@ -831,12 +1126,18 @@ if st.session_state.view_mode == "workspace":
|
||||
# Save updated script first
|
||||
db.update_project_script(st.session_state.project_id, st.session_state.script_data)
|
||||
|
||||
t0 = perf_counter()
|
||||
output_path = composer.compose_from_script(
|
||||
script=st.session_state.script_data,
|
||||
video_map=st.session_state.scene_videos,
|
||||
bgm_path=bgm_path,
|
||||
output_name=f"final_{st.session_state.project_id}_{int(time.time())}" # Unique name for history
|
||||
output_name=f"final_{st.session_state.project_id}_{int(time.time())}", # Unique name for history
|
||||
project_id=st.session_state.project_id,
|
||||
)
|
||||
_record_metrics(st.session_state.project_id, {
|
||||
"compose_s": round(perf_counter() - t0, 3),
|
||||
"bgm_used": bool(bgm_path and Path(bgm_path).exists()),
|
||||
})
|
||||
st.session_state.final_video = output_path
|
||||
db.save_asset(st.session_state.project_id, 0, "final_video", "completed", local_path=output_path)
|
||||
|
||||
@@ -902,16 +1203,25 @@ if st.session_state.view_mode == "workspace":
|
||||
# 智能匹配 BGM:根据脚本 bgm_style 匹配
|
||||
bgm_style = st.session_state.script_data.get("bgm_style", "")
|
||||
bgm_path = match_bgm_by_style(bgm_style, config.ASSETS_DIR / "bgm")
|
||||
if bgm_path and not Path(bgm_path).exists():
|
||||
st.warning(f"推荐的 BGM 文件不存在:{bgm_path}(本次将不含 BGM)")
|
||||
bgm_path = None
|
||||
|
||||
try:
|
||||
# 首次合成也加上时间戳
|
||||
output_name = f"final_{st.session_state.project_id}_{int(time.time())}"
|
||||
t0 = perf_counter()
|
||||
output_path = composer.compose_from_script(
|
||||
script=st.session_state.script_data,
|
||||
video_map=st.session_state.scene_videos,
|
||||
bgm_path=bgm_path,
|
||||
output_name=output_name
|
||||
output_name=output_name,
|
||||
project_id=st.session_state.project_id,
|
||||
)
|
||||
_record_metrics(st.session_state.project_id, {
|
||||
"compose_s": round(perf_counter() - t0, 3),
|
||||
"bgm_used": bool(bgm_path and Path(bgm_path).exists()),
|
||||
})
|
||||
st.session_state.final_video = output_path
|
||||
db.save_asset(st.session_state.project_id, 0, "final_video", "completed", local_path=output_path)
|
||||
db.update_project_status(st.session_state.project_id, "completed")
|
||||
|
||||
Reference in New Issue
Block a user