feat: Add comprehensive timeline editor with frame editing and regeneration capabilities

This commit is contained in:
empty
2026-01-05 14:48:43 +08:00
parent 7d78dcd078
commit ca018a9b1f
68 changed files with 14904 additions and 57 deletions

View File

@@ -0,0 +1,163 @@
# Copyright (C) 2025 AIDC-AI
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Character Memory component for web UI
"""
import streamlit as st
from typing import List
from web.i18n import tr
from web.utils.streamlit_helpers import safe_rerun
from pixelle_video.services.quality import CharacterMemory, Character, CharacterType
def render_active_characters_badge():
"""Render a small badge showing active characters count (for Home page)"""
if "character_memory" not in st.session_state:
return
memory = st.session_state.character_memory
active_count = sum(1 for c in memory.characters if getattr(c, 'is_active', True))
if active_count > 0:
st.info(f"👤 {tr('character.registered_count', count=active_count)} (Active)", icon="")
def render_character_memory():
"""Render the character memory management interface"""
st.markdown(f"### {tr('character.title')}")
st.caption(tr("character.desc"))
# Initialize CharacterMemory in session state if not present
if "character_memory" not in st.session_state:
st.session_state.character_memory = CharacterMemory()
memory = st.session_state.character_memory
# --- Character Registration Form ---
with st.expander(tr("character.add_button"), expanded=False):
with st.form("add_character_form", clear_on_submit=True):
col1, col2 = st.columns(2)
with col1:
name = st.text_input(tr("character.name"), key="new_char_name")
char_type = st.selectbox(
tr("character.type"),
options=[t.value for t in CharacterType],
format_func=lambda x: tr(f"character.type.{x}"),
key="new_char_type"
)
with col2:
features = st.text_input(tr("character.features"), placeholder="e.g., glasses, scar, red hat", key="new_char_features")
# Image upload placeholder (Streamlit doesn't support persistent file paths easily in memory objects)
ref_image = st.file_uploader(tr("character.ref_image"), type=["png", "jpg", "jpeg"], key="new_char_image")
appearance = st.text_area(tr("character.appearance"), placeholder="e.g., young man with short black hair", key="new_char_appearance")
clothing = st.text_area(tr("character.clothing"), placeholder="e.g., blue t-shirt and jeans", key="new_char_clothing")
submit = st.form_submit_button(tr("character.add_button"), use_container_width=True)
if submit:
if not name:
st.error(tr("error.missing_field", field=tr("character.name")))
else:
import os
import shutil
from pathlib import Path
feature_list = [f.strip() for f in features.split(",") if f.strip()]
# Handle ref image upload
ref_path = None
if ref_image:
# Save to a temporary storage or artifacts dir
upload_dir = Path("/Users/yuanjiantsui/projects/Pixelle-Video/temp/characters")
upload_dir.mkdir(parents=True, exist_ok=True)
ref_path = str(upload_dir / f"{name}_{ref_image.name}")
with open(ref_path, "wb") as f:
f.write(ref_image.getbuffer())
char = memory.register_character(
name=name,
appearance_description=appearance,
clothing_description=clothing,
distinctive_features=feature_list,
character_type=CharacterType(char_type)
)
if ref_path:
char.add_reference_image(ref_path, set_as_primary=True)
st.success(f"{name} registered!")
safe_rerun()
# --- Character List ---
st.markdown("---")
characters = memory.characters
if not characters:
st.info(tr("character.no_characters"))
else:
st.markdown(tr("character.registered_count", count=len(characters)))
# Display characters in a grid
cols = st.columns(3)
for i, char in enumerate(characters):
with cols[i % 3]:
with st.container(border=True):
# Header with Status Toggle
head_col, action_col = st.columns([3, 1])
with head_col:
st.markdown(f"**{char.name}**")
st.caption(tr(f"character.type.{char.character_type.value}"))
with action_col:
# Toggle for activation (defensive access for cached objects)
current_active = getattr(char, 'is_active', True)
new_state = st.toggle("On", value=current_active, key=f"tog_{char.id}", label_visibility="collapsed")
if new_state != current_active:
char.is_active = new_state
safe_rerun()
# Image Preview
if char.primary_reference:
st.image(char.primary_reference, use_container_width=True)
else:
st.empty()
if char.appearance_description:
st.caption(f"📝 {char.appearance_description}")
if char.clothing_description:
st.caption(f"👕 {char.clothing_description}")
if char.distinctive_features:
st.markdown(", ".join([f"`{f}`" for f in char.distinctive_features]))
st.markdown("---")
# Delete Button at bottom
if st.button("🗑️ " + tr("history.task_card.delete"), key=f"del_{char.id}", use_container_width=True):
# Basic delete logic
del memory._characters[char.id]
if char.name.lower() in memory._name_index:
del memory._name_index[char.name.lower()]
safe_rerun()
# Provide consistency prompt suggestion if characters exist
if characters:
with st.expander("💡 Character Consistency Prompt", expanded=False):
st.code(
", ".join([char.get_prompt_injection() for char in characters]),
language="text"
)

View File

@@ -69,8 +69,25 @@ def render_single_output(pixelle_video, video_params):
if not config_manager.validate():
st.warning(tr("settings.not_configured"))
# Generate Button
if st.button(tr("btn.generate"), type="primary", use_container_width=True):
# Mode selection: sync vs async
col_mode, col_btn = st.columns([1, 2])
with col_mode:
async_mode = st.checkbox(
"🚀 后台生成",
value=False,
help="勾选后任务将提交到后台,可以继续创作其他视频"
)
with col_btn:
generate_clicked = st.button(
tr("btn.generate") + (" (后台)" if async_mode else ""),
type="primary",
use_container_width=True
)
# Generate Button clicked
if generate_clicked:
# Validate system configuration
if not config_manager.validate():
st.error(tr("settings.not_configured"))
@@ -81,6 +98,76 @@ def render_single_output(pixelle_video, video_params):
st.error(tr("error.input_required"))
st.stop()
# ============================================
# Async Mode: Submit to background queue
# ============================================
if async_mode:
import requests
try:
# Prepare request payload
api_payload = {
"text": text,
"mode": mode,
"title": title if title else None,
"n_scenes": n_scenes,
"split_mode": split_mode,
"media_workflow": workflow_key,
"frame_template": frame_template,
"prompt_prefix": prompt_prefix,
"bgm_path": bgm_path,
"bgm_volume": bgm_volume if bgm_path else 0.2,
"tts_inference_mode": tts_mode,
}
# Add TTS parameters
if tts_mode == "local":
api_payload["tts_voice"] = selected_voice
api_payload["tts_speed"] = tts_speed
else:
api_payload["tts_workflow"] = tts_workflow_key
if ref_audio_path:
api_payload["ref_audio"] = str(ref_audio_path)
# Add template params
if custom_values_for_video:
api_payload["template_params"] = custom_values_for_video
# Submit to async API
response = requests.post(
"http://localhost:8000/api/video/generate/async",
json=api_payload,
timeout=30
)
if response.status_code == 200:
result = response.json()
task_id = result.get("task_id", "unknown")
# Show success dialog
st.success(f"✅ 任务已提交任务ID: `{task_id}`")
st.info("📋 可以在「任务中心」查看进度")
# Clear form button
if st.button("🆕 开始新创作", type="primary", use_container_width=True):
# Clear session state for form fields
for key in ["input_text", "video_title"]:
if key in st.session_state:
del st.session_state[key]
st.rerun()
else:
st.error(f"提交失败: {response.text}")
except requests.exceptions.ConnectionError:
st.error("❌ 无法连接到 API 服务器,请确保后端已启动")
except Exception as e:
st.error(f"❌ 提交失败: {e}")
st.stop()
# ============================================
# Sync Mode: Original blocking generation
# ============================================
# Show progress
progress_bar = st.progress(0)
status_text = st.empty()
@@ -139,6 +226,8 @@ def render_single_output(pixelle_video, video_params):
"progress_callback": update_progress,
"media_width": st.session_state.get('template_media_width'),
"media_height": st.session_state.get('template_media_height'),
# Character memory for visual consistency
"character_memory": st.session_state.get('character_memory'),
}
# Add TTS parameters based on mode
@@ -190,17 +279,57 @@ def render_single_output(pixelle_video, video_params):
if os.path.exists(result.video_path):
st.video(result.video_path)
# Buttons row
col_download, col_editor = st.columns(2)
# Download button
with open(result.video_path, "rb") as video_file:
video_bytes = video_file.read()
video_filename = os.path.basename(result.video_path)
st.download_button(
label="⬇️ 下载视频" if get_language() == "zh_CN" else "⬇️ Download Video",
data=video_bytes,
file_name=video_filename,
mime="video/mp4",
use_container_width=True
)
with col_download:
with open(result.video_path, "rb") as video_file:
video_bytes = video_file.read()
video_filename = os.path.basename(result.video_path)
st.download_button(
label="⬇️ 下载视频" if get_language() == "zh_CN" else "⬇️ Download Video",
data=video_bytes,
file_name=video_filename,
mime="video/mp4",
use_container_width=True
)
# Open in Editor button
with col_editor:
# Get task_id from result path (format: output/{task_id}/...)
task_id = None
try:
path_parts = Path(result.video_path).parts
if "output" in path_parts:
output_idx = path_parts.index("output")
if output_idx + 1 < len(path_parts):
task_id = path_parts[output_idx + 1]
except:
pass
if task_id:
editor_url = f"http://localhost:3001/editor?storyboard_id={task_id}"
st.markdown(
f'''
<a href="{editor_url}" target="_blank" style="text-decoration: none;">
<button style="
width: 100%;
padding: 0.5rem 1rem;
background-color: #262730;
color: white;
border: 1px solid #262730;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 400;
">
✏️ {"在编辑器中打开" if get_language() == "zh_CN" else "Open in Editor"}
</button>
</a>
''',
unsafe_allow_html=True
)
else:
st.error(tr("status.video_not_found", path=result.video_path))