1044 lines
55 KiB
Python
1044 lines
55 KiB
Python
import base64
|
||
import configparser
|
||
import json
|
||
import logging
|
||
import mimetypes
|
||
import os
|
||
import re
|
||
import sys
|
||
import threading
|
||
import time
|
||
import uuid
|
||
from dataclasses import dataclass, field
|
||
from enum import Enum, auto
|
||
|
||
import boto3
|
||
import httpx
|
||
import requests
|
||
from PySide6.QtCore import Qt, QSize, QObject, Signal
|
||
from PySide6.QtGui import (QPixmap, QIcon, QColor, QFontDatabase, QFont,
|
||
QPainter, QAction)
|
||
from PySide6.QtSvg import QSvgRenderer
|
||
from PySide6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel,
|
||
QLineEdit, QPushButton, QFileDialog, QMessageBox, QTextEdit,
|
||
QSpinBox, QGraphicsDropShadowEffect, QGridLayout, QSizePolicy,
|
||
QAbstractSpinBox, QListWidget, QListWidgetItem, QProgressBar, QDialog,
|
||
QDialogButtonBox, QInputDialog, QMenu, QFormLayout)
|
||
|
||
# --- 全局配置 ---
|
||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# --- 默认参数 ---
|
||
DEFAULT_PRODUCT = "超短牛仔裙(白色紧身蕾丝短袖)"
|
||
DEFAULT_SCENE = "室内可爱简约的女性卧室"
|
||
DEFAULT_MODEL = "单马尾充满媚态,对自己的性感妩媚十分自信,眼神勾人"
|
||
DEFAULT_DUPLICATE = 1
|
||
DEFAULT_AVAILABLE_TEMPLATES = ["妩媚眼神", "商品展示", "切镜展示"]
|
||
DEFAULT_SELECTED_TEMPLATES = ["妩媚眼神"]
|
||
DEFAULT_OUTPUT = ""
|
||
|
||
# --- API & S3 配置 ---
|
||
HOST = "https://n8n.bowong.cc"
|
||
AWS_ACCESS_KEY_ID = "AKIAYRH5NGRSWHN2L4M6"
|
||
AWS_SECRET_ACCESS_KEY = "kfAqoOmIiyiywi25xaAkJUQbZ/EKDnzvI6NRCW1l"
|
||
S3_BUCKET = "modal-media-cache"
|
||
|
||
# --- LLM 配置 ---
|
||
LLM_API_URL = "https://gateway.bowong.cc/chat/completions"
|
||
LLM_PROVIDER = "gpt-4o-1120"
|
||
DEFAULT_LLM_PROMPT_DATA = {
|
||
"product": {
|
||
"desc": "模特身上的服饰,格式为“下装(上装)”",
|
||
"example": "蓝色牛仔裤(白色T恤)"
|
||
},
|
||
"scene": {
|
||
"desc": "图中的场景",
|
||
"example": "阳光下的海滩"
|
||
},
|
||
"model": {
|
||
"desc": "模特的样貌,包含肤色、发型、发色、神态等",
|
||
"example": "黑色长发皮肤白皙的亚洲女性,正在微笑"
|
||
}
|
||
}
|
||
|
||
|
||
# --- 辅助对话框 ---
|
||
|
||
class PromptConfigDialog(QDialog):
|
||
"""用于结构化配置LLM Prompt的对话框"""
|
||
|
||
def __init__(self, prompt_data, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("配置智能参数Prompt")
|
||
self.setMinimumSize(650, 350)
|
||
self.layout = QVBoxLayout(self)
|
||
|
||
info_label = QLabel("自定义用于“智能获取参数”的说明和示例,核心指令将保持不变以确保格式正确。")
|
||
self.layout.addWidget(info_label)
|
||
|
||
form_layout = QFormLayout()
|
||
form_layout.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapAllRows)
|
||
form_layout.setLabelAlignment(Qt.AlignRight)
|
||
form_layout.setSpacing(10)
|
||
|
||
self.editors = {}
|
||
for key, values in prompt_data.items():
|
||
field_label = QLabel(f"<b>{key.capitalize()}</b> 字段:")
|
||
field_label.setStyleSheet("margin-top: 10px;")
|
||
form_layout.addRow(field_label)
|
||
|
||
desc_edit = QLineEdit(values.get("desc", ""))
|
||
example_edit = QLineEdit(values.get("example", ""))
|
||
form_layout.addRow(" 说明:", desc_edit)
|
||
form_layout.addRow(" 示例:", example_edit)
|
||
self.editors[key] = {"desc": desc_edit, "example": example_edit}
|
||
|
||
self.layout.addLayout(form_layout)
|
||
|
||
self.button_box = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel, self)
|
||
self.button_box.button(QDialogButtonBox.Save).setText("保存")
|
||
self.button_box.button(QDialogButtonBox.Cancel).setText("取消")
|
||
self.button_box.accepted.connect(self.accept)
|
||
self.button_box.rejected.connect(self.reject)
|
||
self.layout.addWidget(self.button_box)
|
||
|
||
def get_prompt_data(self):
|
||
"""从UI控件收集数据并返回字典"""
|
||
new_data = {}
|
||
for key, editor_group in self.editors.items():
|
||
new_data[key] = {
|
||
"desc": editor_group["desc"].text(),
|
||
"example": editor_group["example"].text()
|
||
}
|
||
return new_data
|
||
|
||
|
||
class ImagePreviewDialog(QDialog):
|
||
"""用于显示大图预览的对话框"""
|
||
|
||
def __init__(self, pixmap, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("图片预览")
|
||
self.setWindowFlags(Qt.Dialog | Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowCloseButtonHint)
|
||
self.label = QLabel(self)
|
||
self.label.setAlignment(Qt.AlignCenter)
|
||
screen_geometry = QApplication.primaryScreen().availableGeometry()
|
||
max_size = QSize(int(screen_geometry.width() * 0.8), int(screen_geometry.height() * 0.8))
|
||
scaled_pixmap = pixmap.scaled(max_size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||
self.label.setPixmap(scaled_pixmap)
|
||
layout = QVBoxLayout(self)
|
||
layout.addWidget(self.label)
|
||
self.setLayout(layout)
|
||
self.resize(scaled_pixmap.size())
|
||
|
||
|
||
class TemplateManagerDialog(QDialog):
|
||
"""用于管理可用模板列表的对话框"""
|
||
|
||
def __init__(self, templates, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("管理模板库")
|
||
self.setMinimumSize(400, 300)
|
||
|
||
self.list_widget = QListWidget(self)
|
||
self.list_widget.addItems(templates)
|
||
self.list_widget.setAlternatingRowColors(True)
|
||
|
||
add_button = QPushButton("添加新模板")
|
||
edit_button = QPushButton("编辑选中")
|
||
delete_button = QPushButton("删除选中")
|
||
|
||
button_layout = QHBoxLayout()
|
||
button_layout.addWidget(add_button)
|
||
button_layout.addWidget(edit_button)
|
||
button_layout.addWidget(delete_button)
|
||
button_layout.addStretch()
|
||
|
||
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self)
|
||
self.button_box.button(QDialogButtonBox.Ok).setText("完成")
|
||
self.button_box.button(QDialogButtonBox.Cancel).setText("取消")
|
||
|
||
main_layout = QVBoxLayout(self)
|
||
main_layout.addLayout(button_layout)
|
||
main_layout.addWidget(self.list_widget)
|
||
main_layout.addWidget(self.button_box)
|
||
|
||
add_button.clicked.connect(self.add_template)
|
||
edit_button.clicked.connect(self.edit_template)
|
||
delete_button.clicked.connect(self.delete_template)
|
||
self.button_box.accepted.connect(self.accept)
|
||
self.button_box.rejected.connect(self.reject)
|
||
self.list_widget.itemDoubleClicked.connect(self.edit_template)
|
||
|
||
def add_template(self):
|
||
text, ok = QInputDialog.getText(self, "添加模板", "请输入新的模板名称:")
|
||
if ok and text and text.strip():
|
||
self.list_widget.addItem(text.strip())
|
||
|
||
def edit_template(self):
|
||
current_item = self.list_widget.currentItem()
|
||
if not current_item: return
|
||
text, ok = QInputDialog.getText(self, "编辑模板", "请修改模板名称:", QLineEdit.Normal, current_item.text())
|
||
if ok and text and text.strip():
|
||
current_item.setText(text.strip())
|
||
|
||
def delete_template(self):
|
||
current_item = self.list_widget.currentItem()
|
||
if not current_item: return
|
||
reply = QMessageBox.question(self, "确认删除", f"确定要删除模板 '{current_item.text()}' 吗?",
|
||
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
|
||
if reply == QMessageBox.Yes:
|
||
self.list_widget.takeItem(self.list_widget.row(current_item))
|
||
|
||
def get_templates(self):
|
||
return [self.list_widget.item(i).text() for i in range(self.list_widget.count())]
|
||
|
||
|
||
# --- 辅助类与数据结构 ---
|
||
|
||
class ParamStatus(Enum):
|
||
IDLE, PROCESSING, DONE, FAILED = auto(), auto(), auto(), auto()
|
||
|
||
|
||
@dataclass
|
||
class ImageConfig:
|
||
path: str
|
||
product: str = DEFAULT_PRODUCT
|
||
scene: str = DEFAULT_SCENE
|
||
model: str = DEFAULT_MODEL
|
||
uid: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||
param_status: ParamStatus = ParamStatus.IDLE
|
||
|
||
|
||
class SvgIcon:
|
||
_renderer_cache = {}
|
||
|
||
@classmethod
|
||
def get_icon(cls, svg_content: str, color: QColor = QColor("#000000"), size: QSize = QSize(20, 20)) -> QIcon:
|
||
cache_key = (svg_content, color.rgba(), size.width(), size.height())
|
||
if cache_key in cls._renderer_cache: return cls._renderer_cache[cache_key]
|
||
renderer = QSvgRenderer(svg_content.encode('utf-8'))
|
||
pixmap = QPixmap(size);
|
||
pixmap.fill(Qt.transparent)
|
||
painter = QPainter(pixmap);
|
||
renderer.render(painter)
|
||
painter.setCompositionMode(QPainter.CompositionMode_SourceIn)
|
||
painter.fillRect(pixmap.rect(), color);
|
||
painter.end()
|
||
icon = QIcon(pixmap);
|
||
cls._renderer_cache[cache_key] = icon
|
||
return icon
|
||
|
||
|
||
class FeatherIcons:
|
||
FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-folder"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>'
|
||
PLAY = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-play"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>'
|
||
UP_ARROW = "<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><path fill='currentColor' d='M7 14l5-5 5 5z'/></svg>"
|
||
DOWN_ARROW = "<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><path fill='currentColor' d='M7 10l5 5 5-5z'/></svg>"
|
||
ICON = '<svg t="1752562342796" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9759" width="200" height="200"><path d="M0 384a213.333333 213.333333 0 0 1 213.333333-213.333333h359.253334a213.333333 213.333333 0 0 1 212.992 200.533333l175.786666-93.056A42.666667 42.666667 0 0 1 1024 315.818667v498.176a42.666667 42.666667 0 0 1-62.634667 37.717333l-177.834666-94.208A213.376 213.376 0 0 1 572.629333 938.666667H213.333333a213.333333 213.333333 0 0 1-213.333333-213.333334v-75.861333a42.666667 42.666667 0 1 1 85.333333 0V725.333333a128 128 0 0 0 128 128h359.253334a128 128 0 0 0 128-128v-37.418666a42.666667 42.666667 0 0 1 62.677333-37.717334L938.666667 743.125333V386.730667l-175.402667 92.885333a42.666667 42.666667 0 0 1-62.634667-37.717333V384a128 128 0 0 0-128-128H213.333333a128 128 0 0 0-128 128v90.581333a42.666667 42.666667 0 0 1-85.333333 0V384z" fill="currentColor" p-id="9760"></path><path d="M150.954667 440.874667a118.528 118.528 0 0 1 118.528-118.485334h112.512a118.528 118.528 0 0 1 0 237.013334H269.482667a118.528 118.528 0 0 1-118.528-118.528z m118.528-33.152a33.194667 33.194667 0 1 0 0 66.346666h112.512a33.194667 33.194667 0 0 0 0-66.346666H269.482667z" fill="#75C82B" p-id="9761"></path></svg>'
|
||
WAND = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-wand-2"><path d="M15 4V2m0 14v-2m-5.07 7.03.04-3.57m-5.4-5.42-3.57.04M19.07 4.97l-2.52 2.52m-10.02 10.02-2.52 2.52M4.93 19.07l2.52-2.52m10.02-10.02 2.52-2.52m-4.22 8.78.78.78a2.12 2.12 0 0 0 3 0l1.42-1.42a2.12 2.12 0 0 0 0-3l-.78-.78m-5.63 5.63-.78-.78a2.12 2.12 0 0 0-3 0l-1.42 1.42a2.12 2.12 0 0 0 0 3l.78.78"/></svg>'
|
||
GEAR = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-settings"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V12a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>'
|
||
|
||
|
||
class AspectRatioLabel(QLabel):
|
||
clicked = Signal()
|
||
|
||
def __init__(self, text="", parent=None, aspect_ratio=9.0 / 16.0):
|
||
super().__init__(text, parent)
|
||
self.aspect_ratio = aspect_ratio
|
||
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding);
|
||
self.setMinimumSize(1, 1)
|
||
self._pixmap = QPixmap();
|
||
self.setCursor(Qt.PointingHandCursor)
|
||
|
||
def setPixmap(self, pixmap):
|
||
self._pixmap = pixmap if isinstance(pixmap, QPixmap) and not pixmap.isNull() else QPixmap()
|
||
self.update()
|
||
|
||
def heightForWidth(self, width):
|
||
return int(width / self.aspect_ratio)
|
||
|
||
def hasHeightForWidth(self):
|
||
return True
|
||
|
||
def paintEvent(self, event):
|
||
painter = QPainter(self)
|
||
if self._pixmap.isNull():
|
||
painter.setPen(QColor("#A0AEC0"));
|
||
painter.drawText(self.rect(), Qt.AlignCenter, self.text())
|
||
else:
|
||
size = self.size();
|
||
scaled_pixmap = self._pixmap.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||
x = (size.width() - scaled_pixmap.width()) / 2;
|
||
y = (size.height() - scaled_pixmap.height()) / 2
|
||
painter.drawPixmap(int(x), int(y), scaled_pixmap)
|
||
|
||
def mousePressEvent(self, event):
|
||
if event.button() == Qt.LeftButton and not self._pixmap.isNull(): self.clicked.emit()
|
||
super().mousePressEvent(event)
|
||
|
||
|
||
class StyledSpinBox(QSpinBox):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setButtonSymbols(QAbstractSpinBox.NoButtons)
|
||
self.up_button = QPushButton(self);
|
||
self.down_button = QPushButton(self)
|
||
self.up_button.setIcon(SvgIcon.get_icon(FeatherIcons.UP_ARROW, size=QSize(12, 12)))
|
||
self.down_button.setIcon(SvgIcon.get_icon(FeatherIcons.DOWN_ARROW, size=QSize(12, 12)))
|
||
self.up_button.setCursor(Qt.PointingHandCursor);
|
||
self.down_button.setCursor(Qt.PointingHandCursor)
|
||
self.up_button.clicked.connect(self.stepUp);
|
||
self.down_button.clicked.connect(self.stepDown)
|
||
self.up_button.setObjectName("SpinBoxUpButton");
|
||
self.down_button.setObjectName("SpinBoxDownButton")
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event);
|
||
button_width = 20
|
||
self.up_button.setGeometry(self.width() - button_width, 0, button_width, self.height() // 2)
|
||
self.down_button.setGeometry(self.width() - button_width, self.height() // 2, button_width, self.height() // 2)
|
||
|
||
|
||
class Communications(QObject):
|
||
log_signal = Signal(str, int);
|
||
status_signal = Signal(str);
|
||
generation_done_signal = Signal()
|
||
preview_signal = Signal(str, str);
|
||
params_received_signal = Signal(str, dict)
|
||
param_fetch_failed_signal = Signal(str);
|
||
single_param_op_finished_signal = Signal()
|
||
all_params_op_finished_signal = Signal();
|
||
progress_update_signal = Signal(int, int, str)
|
||
|
||
|
||
class ProgressDialog(QDialog):
|
||
def __init__(self, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("处理中...");
|
||
self.setModal(True);
|
||
self.setFixedSize(400, 120)
|
||
self.setWindowFlags(Qt.Dialog | Qt.CustomizeWindowHint | Qt.WindowTitleHint)
|
||
layout = QVBoxLayout(self);
|
||
self.status_label = QLabel("正在初始化...", self)
|
||
self.status_label.setAlignment(Qt.AlignCenter)
|
||
self.progress_bar = QProgressBar(self);
|
||
self.progress_bar.setTextVisible(True)
|
||
layout.addWidget(self.status_label);
|
||
layout.addWidget(self.progress_bar)
|
||
|
||
def update_progress(self, current, total, text):
|
||
self.status_label.setText(text);
|
||
self.progress_bar.setValue(int((current / total) * 100))
|
||
self.progress_bar.setFormat(f"%p% ({current}/{total})")
|
||
|
||
def reject(self): pass
|
||
|
||
|
||
# --- 主窗口 ---
|
||
class VideoGeneratorGUI(QWidget):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.is_generating = False
|
||
self.is_getting_single_param = False
|
||
self.comm = Communications()
|
||
self.image_configs = []
|
||
self._is_updating_ui = False
|
||
self.current_config_uid = None
|
||
self.config_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.ini") if getattr(sys,
|
||
'frozen',
|
||
False) else os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.ini")
|
||
# -- 初始化可配置参数 --
|
||
self.llm_prompt_data = DEFAULT_LLM_PROMPT_DATA.copy()
|
||
self.available_templates = DEFAULT_AVAILABLE_TEMPLATES[:]
|
||
self.selected_templates = DEFAULT_SELECTED_TEMPLATES[:]
|
||
|
||
self.init_resources()
|
||
self.initUI()
|
||
self.connect_signals()
|
||
|
||
def init_resources(self):
|
||
font_dir = os.path.dirname(__file__) if getattr(sys, 'frozen', False) else os.path.dirname(
|
||
os.path.abspath(__file__))
|
||
font_path = os.path.join(font_dir, 'SourceHanSansCN-Medium.ttf')
|
||
if os.path.exists(font_path):
|
||
font_id = QFontDatabase.addApplicationFont(font_path)
|
||
if font_id != -1:
|
||
self.app_font = QFont(QFontDatabase.applicationFontFamilies(font_id)[0], 10)
|
||
QApplication.instance().setFont(self.app_font);
|
||
return
|
||
logger.warning("字体加载失败,使用默认字体。")
|
||
self.app_font = QFont("Arial", 10)
|
||
|
||
def initUI(self):
|
||
self.setWindowTitle("视频物料生成工具")
|
||
self.setGeometry(100, 100, 1200, 900)
|
||
self.setMinimumSize(1100, 900)
|
||
main_layout = QVBoxLayout(self)
|
||
main_layout.setContentsMargins(20, 20, 20, 20)
|
||
main_layout.setSpacing(20)
|
||
|
||
content_layout = QHBoxLayout();
|
||
content_layout.setSpacing(20)
|
||
params_card = self.create_params_and_preview_card()
|
||
content_layout.addWidget(params_card, 1)
|
||
list_card = self.create_task_list_card()
|
||
content_layout.addWidget(list_card, 1)
|
||
main_layout.addLayout(content_layout, 1)
|
||
|
||
log_card = self.create_log_card()
|
||
main_layout.addWidget(log_card, 1)
|
||
|
||
bottom_layout = QHBoxLayout()
|
||
self.status_bar = QLabel("就绪");
|
||
self.status_bar.setObjectName("StatusBar")
|
||
self.generate_button = QPushButton("开始生成")
|
||
self.generate_button.setObjectName("GenerateButton")
|
||
self.generate_button.setIcon(SvgIcon.get_icon(FeatherIcons.PLAY, QColor("#FFFFFF")))
|
||
bottom_layout.addWidget(self.status_bar);
|
||
bottom_layout.addStretch()
|
||
bottom_layout.addWidget(self.generate_button)
|
||
main_layout.addLayout(bottom_layout)
|
||
|
||
self.progress_dialog = ProgressDialog(self)
|
||
self.apply_stylesheet()
|
||
self.setWindowIcon(SvgIcon.get_icon(FeatherIcons.ICON, QColor("#75C82B")))
|
||
self.toggle_param_view_state(False)
|
||
|
||
def showEvent(self, event):
|
||
super().showEvent(event)
|
||
if not hasattr(self, '_initial_load_done'): self.load_config(); self._initial_load_done = True
|
||
|
||
def create_card(self, title, with_settings_button=False):
|
||
card = QWidget();
|
||
card.setObjectName("Card")
|
||
layout = QVBoxLayout(card);
|
||
layout.setContentsMargins(20, 15, 20, 20);
|
||
layout.setSpacing(15)
|
||
if title:
|
||
title_layout = QHBoxLayout();
|
||
card_title = QLabel(title);
|
||
card_title.setObjectName("CardTitle")
|
||
title_layout.addWidget(card_title);
|
||
title_layout.addStretch()
|
||
if with_settings_button:
|
||
self.prompt_config_button = QPushButton()
|
||
self.prompt_config_button.setIcon(SvgIcon.get_icon(FeatherIcons.GEAR, QColor("#666")))
|
||
self.prompt_config_button.setObjectName("SettingsButton");
|
||
self.prompt_config_button.setToolTip("配置智能获取参数的Prompt")
|
||
title_layout.addWidget(self.prompt_config_button)
|
||
layout.addLayout(title_layout)
|
||
shadow = QGraphicsDropShadowEffect(self);
|
||
shadow.setBlurRadius(25);
|
||
shadow.setXOffset(0);
|
||
shadow.setYOffset(5);
|
||
shadow.setColor(QColor(0, 0, 0, 30));
|
||
card.setGraphicsEffect(shadow)
|
||
return card, layout
|
||
|
||
def create_params_and_preview_card(self):
|
||
card, layout = self.create_card("图片参数配置", with_settings_button=True)
|
||
preview_container = QWidget();
|
||
preview_container_layout = QHBoxLayout(preview_container)
|
||
self.preview_label = AspectRatioLabel("请从右侧列表选择图片");
|
||
self.preview_label.setObjectName("PreviewLabel");
|
||
self.preview_label.setFixedSize(135, 240)
|
||
preview_container_layout.addStretch();
|
||
preview_container_layout.addWidget(self.preview_label);
|
||
preview_container_layout.addStretch()
|
||
layout.addWidget(preview_container)
|
||
grid_layout = QGridLayout();
|
||
grid_layout.setSpacing(15);
|
||
grid_layout.setColumnStretch(1, 1)
|
||
self.product_input = self.create_input_field(grid_layout, 0, "产品:", "", "例如:夏季连衣裙")
|
||
self.scene_input = self.create_input_field(grid_layout, 1, "场景:", "", "例如:海滩、街拍")
|
||
self.model_input = self.create_input_field(grid_layout, 2, "模特:", "", "例如:亚洲女性、金发模特")
|
||
layout.addLayout(grid_layout)
|
||
param_buttons_layout = QHBoxLayout();
|
||
self.get_params_button = QPushButton("智能获取参数");
|
||
self.get_params_button.setIcon(SvgIcon.get_icon(FeatherIcons.WAND, QColor("#333")))
|
||
self.get_all_params_button = QPushButton("一键获取全部");
|
||
self.get_all_params_button.setIcon(SvgIcon.get_icon(FeatherIcons.WAND, QColor("#333")))
|
||
param_buttons_layout.addWidget(self.get_params_button);
|
||
param_buttons_layout.addWidget(self.get_all_params_button)
|
||
layout.addLayout(param_buttons_layout)
|
||
self.param_widgets = [self.product_input, self.scene_input, self.model_input];
|
||
self.param_buttons = [self.get_params_button, self.get_all_params_button]
|
||
layout.addStretch()
|
||
return card
|
||
|
||
def create_task_list_card(self):
|
||
card, layout = self.create_card("任务列表 & 全局配置")
|
||
button_layout = QHBoxLayout();
|
||
self.add_button = QPushButton("添加图片");
|
||
self.remove_button = QPushButton("移除选中")
|
||
button_layout.addWidget(self.add_button);
|
||
button_layout.addWidget(self.remove_button);
|
||
button_layout.addStretch();
|
||
layout.addLayout(button_layout)
|
||
self.image_list_widget = QListWidget();
|
||
self.image_list_widget.setObjectName("ImageList");
|
||
layout.addWidget(self.image_list_widget, 1)
|
||
global_params_label = QLabel("全局参数");
|
||
global_params_label.setObjectName("CardSubTitle");
|
||
layout.addWidget(global_params_label)
|
||
template_grid = QGridLayout();
|
||
template_grid.setSpacing(10)
|
||
self.selected_templates_display = QLineEdit();
|
||
self.selected_templates_display.setReadOnly(True);
|
||
self.selected_templates_display.setPlaceholderText("请选择模板")
|
||
self.select_templates_button = QPushButton("选择模板");
|
||
self.manage_templates_button = QPushButton()
|
||
self.manage_templates_button.setIcon(SvgIcon.get_icon(FeatherIcons.GEAR, QColor("#333")));
|
||
self.manage_templates_button.setToolTip("管理模板库")
|
||
self.manage_templates_button.setObjectName("PathButton") # Re-use style
|
||
template_layout = QHBoxLayout();
|
||
template_layout.setSpacing(5);
|
||
template_layout.addWidget(self.selected_templates_display, 1);
|
||
template_layout.addWidget(self.select_templates_button);
|
||
template_layout.addWidget(self.manage_templates_button)
|
||
template_grid.addWidget(QLabel("模板:"), 0, 0);
|
||
template_grid.addLayout(template_layout, 0, 1)
|
||
self.global_duplicate_input = StyledSpinBox();
|
||
self.global_duplicate_input.setMinimum(1);
|
||
self.global_duplicate_input.setValue(DEFAULT_DUPLICATE);
|
||
self.global_duplicate_input.setObjectName("SpinBox")
|
||
template_grid.addWidget(QLabel("重复:"), 1, 0);
|
||
template_grid.addWidget(self.global_duplicate_input, 1, 1);
|
||
layout.addLayout(template_grid)
|
||
output_label = QLabel("视频输出文件夹:");
|
||
output_label.setObjectName("CardSubTitle")
|
||
self.output_input = QLineEdit(DEFAULT_OUTPUT);
|
||
self.output_input.setPlaceholderText("选择视频输出文件夹")
|
||
self.output_button = QPushButton();
|
||
self.output_button.setIcon(SvgIcon.get_icon(FeatherIcons.FOLDER, QColor("#333")));
|
||
self.output_button.setObjectName("PathButton")
|
||
path_layout = QHBoxLayout();
|
||
path_layout.addWidget(self.output_input);
|
||
path_layout.addWidget(self.output_button)
|
||
layout.addWidget(output_label);
|
||
layout.addLayout(path_layout)
|
||
return card
|
||
|
||
def create_input_field(self, layout, row, label_text, value, placeholder):
|
||
label = QLabel(label_text);
|
||
line_edit = QLineEdit(value);
|
||
line_edit.setPlaceholderText(placeholder)
|
||
layout.addWidget(label, row, 0);
|
||
layout.addWidget(line_edit, row, 1);
|
||
return line_edit
|
||
|
||
def create_log_card(self):
|
||
card, layout = self.create_card("运行日志");
|
||
self.log_text = QTextEdit();
|
||
self.log_text.setReadOnly(True);
|
||
self.log_text.setObjectName("LogText");
|
||
layout.addWidget(self.log_text);
|
||
return card
|
||
|
||
def apply_stylesheet(self):
|
||
style = f"""
|
||
QWidget {{ font-family: '{self.app_font.family()}'; }}
|
||
VideoGeneratorGUI {{ background-color: #F4F6F8; }}
|
||
#Card {{ background-color: #FFFFFF; border-radius: 12px; }}
|
||
#CardTitle {{ font-size: 16px; font-weight: bold; color: #2D3748; padding-bottom: 5px; border-bottom: 1px solid #E2E8F0; }}
|
||
#CardSubTitle {{ font-size: 14px; font-weight: bold; color: #4A5568; margin-top: 10px; }}
|
||
QLabel {{ font-size: 14px; color: #4A5568; }}
|
||
QLineEdit, #SpinBox {{ background-color: #F7FAFC; border: 1px solid #E2E8F0; border-radius: 8px; padding: 10px; font-size: 14px; color: #2D3748; }}
|
||
QLineEdit:focus, #SpinBox:focus {{ border-color: #4299E1; background-color: #FFFFFF; }}
|
||
QLineEdit:disabled, #SpinBox:disabled, QPushButton:disabled, QListWidget:disabled {{ background-color: #EDF2F7; color: #A0AEC0; border-color: #E2E8F0; }}
|
||
QLineEdit[readOnly=true] {{ background-color: #EDF2F7; }}
|
||
#SpinBox {{ padding-right: 20px; }}
|
||
#ImageList {{ border: 1px solid #E2E8F0; border-radius: 8px; }}
|
||
#ImageList::item {{ padding: 8px; }} #ImageList::item:selected {{ background-color: #EBF8FF; border-left: 3px solid #4299E1; color: #2C5282; }}
|
||
#PreviewLabel {{ border: 2px dashed #CBD5E0; border-radius: 8px; color: #A0AEC0; }}
|
||
#LogText {{ background-color: #1A202C; color: #F7FAFC; border: none; border-radius: 8px; font-family: 'Monaco', 'Courier New', 'monospace'; font-size: 13px; padding: 10px; }}
|
||
QPushButton {{ background-color: #FFFFFF; border: 1px solid #D1D5DB; border-radius: 6px; padding: 8px 15px; font-size: 14px; }}
|
||
QPushButton:hover {{ background-color: #F9FAFB; }}
|
||
#GenerateButton {{ background-color: #4299E1; color: white; font-weight: bold; border: none; padding: 12px 25px; }}
|
||
#GenerateButton:hover {{ background-color: #3182CE; }} #GenerateButton:disabled {{ background-color: #A0AEC0; }}
|
||
#PathButton, #SettingsButton {{ min-width: 36px; max-width: 36px; padding: 8px; }}
|
||
"""
|
||
self.setStyleSheet(style)
|
||
|
||
def connect_signals(self):
|
||
self.generate_button.clicked.connect(self.start_generation);
|
||
self.add_button.clicked.connect(self.add_images)
|
||
self.remove_button.clicked.connect(self.remove_selected_image);
|
||
self.output_button.clicked.connect(self.select_output_path)
|
||
self.image_list_widget.currentItemChanged.connect(self.on_current_image_changed)
|
||
self.product_input.editingFinished.connect(self.save_current_parameters)
|
||
self.scene_input.editingFinished.connect(self.save_current_parameters);
|
||
self.model_input.editingFinished.connect(self.save_current_parameters)
|
||
self.preview_label.clicked.connect(self.show_large_preview);
|
||
self.prompt_config_button.clicked.connect(self.open_prompt_config)
|
||
self.select_templates_button.clicked.connect(self.show_template_selection_menu);
|
||
self.manage_templates_button.clicked.connect(self.open_template_manager)
|
||
self.get_params_button.clicked.connect(self.get_intelligent_params);
|
||
self.get_all_params_button.clicked.connect(self.get_all_intelligent_params)
|
||
self.comm.log_signal.connect(self.log);
|
||
self.comm.status_signal.connect(self.update_status)
|
||
self.comm.generation_done_signal.connect(self.on_generation_finished);
|
||
self.comm.params_received_signal.connect(self.on_params_received)
|
||
self.comm.param_fetch_failed_signal.connect(self.on_param_fetch_failed);
|
||
self.comm.single_param_op_finished_signal.connect(self.on_single_param_op_finished)
|
||
self.comm.all_params_op_finished_signal.connect(self.on_all_params_op_finished);
|
||
self.comm.progress_update_signal.connect(self.progress_dialog.update_progress)
|
||
self.comm.preview_signal.connect(self.show_preview_box)
|
||
|
||
def toggle_batch_ui_lock(self, locked: bool):
|
||
widgets_to_lock = [
|
||
self.image_list_widget, self.add_button, self.remove_button, self.output_input, self.output_button,
|
||
self.select_templates_button, self.manage_templates_button, self.global_duplicate_input,
|
||
self.prompt_config_button,
|
||
self.get_params_button, self.get_all_params_button, self.generate_button, *self.param_widgets]
|
||
for widget in widgets_to_lock: widget.setDisabled(locked)
|
||
if not locked and self.image_list_widget.currentItem(): self.toggle_param_view_state(True)
|
||
|
||
def open_prompt_config(self):
|
||
dialog = PromptConfigDialog(self.llm_prompt_data, self)
|
||
if dialog.exec():
|
||
self.llm_prompt_data = dialog.get_prompt_data()
|
||
self.log("智能参数Prompt已更新。", logging.INFO)
|
||
|
||
def show_large_preview(self):
|
||
if self.preview_label._pixmap.isNull(): return
|
||
ImagePreviewDialog(self.preview_label._pixmap, self).exec()
|
||
|
||
def open_template_manager(self):
|
||
dialog = TemplateManagerDialog(self.available_templates, self)
|
||
if dialog.exec():
|
||
self.available_templates = dialog.get_templates()
|
||
self.selected_templates = [t for t in self.selected_templates if t in self.available_templates]
|
||
self.update_selected_templates_display();
|
||
self.log("模板库已更新。", logging.INFO)
|
||
|
||
def show_template_selection_menu(self):
|
||
menu = QMenu(self)
|
||
for template in self.available_templates:
|
||
action = QAction(template, self);
|
||
action.setCheckable(True)
|
||
if template in self.selected_templates: action.setChecked(True)
|
||
action.triggered.connect(lambda checked, t=template: self.on_template_selected(t, checked))
|
||
menu.addAction(action)
|
||
menu.exec(self.select_templates_button.mapToGlobal(self.select_templates_button.rect().bottomLeft()))
|
||
|
||
def on_template_selected(self, template, is_checked):
|
||
if is_checked and template not in self.selected_templates:
|
||
self.selected_templates.append(template)
|
||
elif not is_checked and template in self.selected_templates:
|
||
self.selected_templates.remove(template)
|
||
self.update_selected_templates_display()
|
||
|
||
def update_selected_templates_display(self):
|
||
self.selected_templates_display.setText(", ".join(self.selected_templates))
|
||
|
||
def add_images(self):
|
||
file_paths, _ = QFileDialog.getOpenFileNames(self, "选择图片", "", "图片文件 (*.png *.jpg *.jpeg)")
|
||
if not file_paths: return
|
||
last_config = self.image_configs[-1] if self.image_configs else None
|
||
for path in file_paths:
|
||
new_config = ImageConfig(path=path)
|
||
if last_config: new_config.product, new_config.scene, new_config.model = last_config.product, last_config.scene, last_config.model
|
||
self.image_configs.append(new_config)
|
||
item = QListWidgetItem(os.path.basename(path));
|
||
item.setData(Qt.UserRole, new_config.uid)
|
||
self.image_list_widget.addItem(item)
|
||
self.log(f"添加了 {len(file_paths)} 张新图片。", logging.INFO)
|
||
if self.image_list_widget.count() > 0: self.image_list_widget.setCurrentRow(
|
||
self.image_list_widget.count() - len(file_paths))
|
||
|
||
def remove_selected_image(self):
|
||
current_item = self.image_list_widget.currentItem()
|
||
if not current_item: return
|
||
uid_to_remove = current_item.data(Qt.UserRole)
|
||
self.image_configs = [cfg for cfg in self.image_configs if cfg.uid != uid_to_remove]
|
||
self.image_list_widget.takeItem(self.image_list_widget.row(current_item))
|
||
self.log(f"移除了图片: {current_item.text()}", logging.INFO)
|
||
|
||
def on_current_image_changed(self, current_item, _):
|
||
if self._is_updating_ui: return
|
||
if not current_item:
|
||
self.clear_param_view();
|
||
self.toggle_param_view_state(False);
|
||
self.current_config_uid = None;
|
||
return
|
||
uid = current_item.data(Qt.UserRole)
|
||
if config := self.get_config_by_uid(uid):
|
||
self.current_config_uid = uid;
|
||
self.populate_param_view(config);
|
||
self.toggle_param_view_state(True)
|
||
|
||
def populate_param_view(self, config: ImageConfig):
|
||
self._is_updating_ui = True
|
||
if (pixmap := QPixmap(config.path)).isNull():
|
||
self.preview_label.setText("预览失败");
|
||
self.preview_label.setPixmap(QPixmap())
|
||
else:
|
||
self.preview_label.setPixmap(pixmap)
|
||
if config.param_status == ParamStatus.PROCESSING:
|
||
for widget in self.param_widgets: widget.setText(""); widget.setPlaceholderText("正在智能获取中...")
|
||
else:
|
||
self.product_input.setText(config.product);
|
||
self.product_input.setPlaceholderText("例如:夏季连衣裙")
|
||
self.scene_input.setText(config.scene);
|
||
self.scene_input.setPlaceholderText("例如:海滩、街拍")
|
||
self.model_input.setText(config.model);
|
||
self.model_input.setPlaceholderText("例如:亚洲女性、金发模特")
|
||
self._is_updating_ui = False
|
||
|
||
def save_current_parameters(self):
|
||
if self._is_updating_ui or not self.current_config_uid: return
|
||
if (
|
||
config := self.get_config_by_uid(
|
||
self.current_config_uid)) and config.param_status != ParamStatus.PROCESSING:
|
||
config.product, config.scene, config.model = self.product_input.text(), self.scene_input.text(), self.model_input.text()
|
||
|
||
def get_config_by_uid(self, uid: str) -> ImageConfig | None:
|
||
return next((config for config in self.image_configs if config.uid == uid), None)
|
||
|
||
def clear_param_view(self):
|
||
self._is_updating_ui = True;
|
||
[w.clear() for w in self.param_widgets]
|
||
self.preview_label.setText("请从右侧列表选择图片");
|
||
self.preview_label.setPixmap(QPixmap());
|
||
self._is_updating_ui = False
|
||
|
||
def toggle_param_view_state(self, enabled: bool):
|
||
if not enabled: [w.setDisabled(True) for w in self.param_widgets + self.param_buttons]; return
|
||
config = self.get_config_by_uid(self.current_config_uid)
|
||
is_processing = config and config.param_status == ParamStatus.PROCESSING
|
||
[w.setDisabled(is_processing) for w in self.param_widgets]
|
||
[b.setDisabled(self.is_getting_single_param or self.is_generating) for b in self.param_buttons]
|
||
self.prompt_config_button.setDisabled(self.is_generating or self.is_getting_single_param)
|
||
|
||
def load_config(self):
|
||
if not os.path.exists(self.config_path):
|
||
self.log("未找到配置文件,将使用默认设置。", logging.INFO);
|
||
self.update_selected_templates_display();
|
||
return
|
||
self.log("正在加载配置...", logging.INFO)
|
||
config = configparser.ConfigParser();
|
||
config.read(self.config_path, "utf-8")
|
||
if 'General' in config:
|
||
gen_conf = config['General'];
|
||
self.output_input.setText(gen_conf.get('OUTPUT', DEFAULT_OUTPUT))
|
||
self.global_duplicate_input.setValue(gen_conf.getint('DUPLICATE', DEFAULT_DUPLICATE))
|
||
self.available_templates = [t.strip() for t in gen_conf.get('AvailableTemplates', "").split(',') if
|
||
t.strip()] or DEFAULT_AVAILABLE_TEMPLATES[:]
|
||
self.selected_templates = [t.strip() for t in gen_conf.get('SelectedTemplates', "").split(',') if
|
||
t.strip()] or DEFAULT_SELECTED_TEMPLATES[:]
|
||
self.update_selected_templates_display()
|
||
if 'LLM' in config and (prompt_data_str := config['LLM'].get('PromptData')):
|
||
try:
|
||
self.llm_prompt_data = json.loads(prompt_data_str)
|
||
except json.JSONDecodeError:
|
||
self.log("加载Prompt配置失败,使用默认值。", logging.WARNING)
|
||
image_sections = sorted([s for s in config.sections() if s.startswith('Image_')])
|
||
for section_name in image_sections:
|
||
section = config[section_name];
|
||
path = section.get('path')
|
||
if not path or not os.path.exists(path): self.log(f"配置文件图片路径无效: {path}",
|
||
logging.WARNING); continue
|
||
cfg = ImageConfig(path=path, product=section.get('product', DEFAULT_PRODUCT),
|
||
scene=section.get('scene', DEFAULT_SCENE), model=section.get('model', DEFAULT_MODEL))
|
||
self.image_configs.append(cfg);
|
||
item = QListWidgetItem(os.path.basename(path));
|
||
item.setData(Qt.UserRole, cfg.uid);
|
||
self.image_list_widget.addItem(item)
|
||
if self.image_list_widget.count() > 0: self.image_list_widget.setCurrentRow(0)
|
||
self.log(f"配置加载完成,共 {len(self.image_configs)} 个任务。", logging.INFO)
|
||
|
||
def closeEvent(self, event):
|
||
self.save_current_parameters()
|
||
if QMessageBox.question(self, '确认退出', '确定要保存当前配置并退出吗?', QMessageBox.Yes | QMessageBox.No,
|
||
QMessageBox.Yes) == QMessageBox.Yes:
|
||
self.save_config_to_file();
|
||
event.accept()
|
||
else:
|
||
event.ignore()
|
||
|
||
def save_config_to_file(self):
|
||
self.log("正在保存配置...", logging.INFO);
|
||
config = configparser.ConfigParser()
|
||
config['General'] = {'OUTPUT': self.output_input.text(), 'DUPLICATE': str(self.global_duplicate_input.value()),
|
||
'AvailableTemplates': ",".join(self.available_templates),
|
||
'SelectedTemplates': ",".join(self.selected_templates)}
|
||
config['LLM'] = {'PromptData': json.dumps(self.llm_prompt_data, ensure_ascii=False, indent=2)}
|
||
for i, cfg in enumerate(self.image_configs): config[f'Image_{i}'] = {'path': cfg.path, 'product': cfg.product,
|
||
'scene': cfg.scene, 'model': cfg.model}
|
||
try:
|
||
with open(self.config_path, "w", encoding="utf-8") as f:
|
||
config.write(f)
|
||
self.log("配置已成功保存。", logging.INFO)
|
||
except Exception as e:
|
||
self.log(f"保存配置失败: {e}", logging.ERROR)
|
||
|
||
def start_generation(self):
|
||
if self.is_generating or self.is_getting_single_param: return
|
||
if not self.image_configs: QMessageBox.critical(self, "错误", "请至少添加一张图片到任务列表。"); return
|
||
if not self.output_input.text() or not os.path.isdir(self.output_input.text()): QMessageBox.critical(self,
|
||
"错误",
|
||
"请选择一个有效的视频输出文件夹。"); return
|
||
if not self.selected_templates: QMessageBox.critical(self, "错误", "请至少选择一个生成模板。"); return
|
||
self.is_generating = True;
|
||
self.generate_button.setDisabled(True);
|
||
self.generate_button.setText("生成中...")
|
||
self.update_status("开始生成任务...");
|
||
self.log(f"--- 任务开始,共 {len(self.image_configs)} 张图片 ---", logging.INFO)
|
||
threading.Thread(target=self.generate_video_thread,
|
||
args=(self.image_configs[:], self.global_duplicate_input.value(), self.selected_templates[:]),
|
||
daemon=True).start()
|
||
|
||
def on_generation_finished(self):
|
||
self.is_generating = False;
|
||
self.generate_button.setDisabled(False);
|
||
self.generate_button.setText("开始生成")
|
||
self.update_status("所有任务完成");
|
||
self.log("--- 所有任务完成 ---", logging.INFO)
|
||
|
||
def generate_video_thread(self, configs_to_process, duplicate, templates):
|
||
try:
|
||
total_images = len(configs_to_process)
|
||
if not templates: self.comm.log_signal.emit("全局模板为空,任务中止。", logging.ERROR); return
|
||
for idx, config in enumerate(configs_to_process):
|
||
image_basename = os.path.basename(config.path)
|
||
self.comm.log_signal.emit(f"--- ({idx + 1}/{total_images}) 开始处理: {image_basename} ---",
|
||
logging.INFO)
|
||
image_url = self.upload_to_s3(config.path)
|
||
if not image_url: self.comm.log_signal.emit(f"图片 {image_basename} 上传失败,跳过。",
|
||
logging.ERROR); continue
|
||
self.comm.status_signal.emit(f"正在为 {image_basename} 生成视频...")
|
||
for template in templates:
|
||
self.comm.log_signal.emit(f"处理模板: {template} (图片: {image_basename})", logging.INFO)
|
||
if video_urls := self.generate_video_from_config(image_url, template, config, duplicate):
|
||
for url in video_urls:
|
||
if url and isinstance(url, str):
|
||
self.download_video(url, template, config.path)
|
||
else:
|
||
self.comm.log_signal.emit(f"收到无效视频URL '{url}',跳过下载。", logging.WARNING)
|
||
else:
|
||
self.comm.log_signal.emit(f"模板 {template} 生成失败或未返回URL。", logging.ERROR)
|
||
except Exception as e:
|
||
self.comm.log_signal.emit(f"线程发生未知错误: {e}", logging.ERROR);
|
||
logger.exception(
|
||
"Error in generation thread")
|
||
finally:
|
||
self.comm.generation_done_signal.emit()
|
||
|
||
def _build_llm_prompt(self):
|
||
"""从结构化数据动态构建LLM Prompt字符串"""
|
||
parts = ["请详细描述图中的信息,并以json格式返回。包含以下key:"]
|
||
for key, data in self.llm_prompt_data.items():
|
||
parts.append(f'- {key}:{data["desc"]},例如:“{data["example"]}”。')
|
||
return "\n".join(parts)
|
||
|
||
def _get_params_worker_logic(self, config: ImageConfig):
|
||
try:
|
||
with open(config.path, "rb") as image_file:
|
||
image_data = image_file.read()
|
||
base64_encoded_data = base64.b64encode(image_data).decode('utf-8')
|
||
mime_type, _ = mimetypes.guess_type(config.path);
|
||
mime_type = mime_type or 'image/jpeg'
|
||
payload = {"model": LLM_PROVIDER, "messages": [{"role": "user", "content": [
|
||
{"type": "text", "text": self._build_llm_prompt()},
|
||
{"type": "image_url", "image_url": {"url": f"data:{mime_type};base64,{base64_encoded_data}"}}
|
||
]}], "temperature": 0.2, "max_tokens": 500}
|
||
with httpx.Client(timeout=httpx.Timeout(120.0, connect=15)) as session:
|
||
resp = session.post(LLM_API_URL,
|
||
headers={"Content-Type": "application/json", "Accept": "application/json",
|
||
"Authorization": "Bearer auth-bowong7777"}, json=payload)
|
||
resp.raise_for_status()
|
||
content_str = re.sub(r'```json\s*|\s*```', '', resp.json()['choices'][0]['message']['content']).strip()
|
||
params = json.loads(content_str)
|
||
self.comm.params_received_signal.emit(config.uid, params)
|
||
except Exception as e:
|
||
self.comm.log_signal.emit(f"智能获取参数失败 ({os.path.basename(config.path)}): {e}", logging.ERROR)
|
||
self.comm.param_fetch_failed_signal.emit(config.uid);
|
||
logger.exception("Error in LLM worker")
|
||
|
||
def _get_params_worker(self, config: ImageConfig):
|
||
try:
|
||
self.comm.log_signal.emit(f"正在读取图片并请求LLM: {os.path.basename(config.path)}", logging.INFO)
|
||
self._get_params_worker_logic(config)
|
||
finally:
|
||
self.comm.single_param_op_finished_signal.emit()
|
||
|
||
def _get_all_params_worker(self, configs_to_process):
|
||
total = len(configs_to_process)
|
||
for i, config in enumerate(configs_to_process):
|
||
filename = os.path.basename(config.path)
|
||
self.comm.progress_update_signal.emit(i, total, f"正在处理: {filename}")
|
||
config.param_status = ParamStatus.PROCESSING
|
||
self._get_params_worker_logic(config)
|
||
self.comm.progress_update_signal.emit(i + 1, total, f"已完成: {filename}")
|
||
time.sleep(0.5)
|
||
self.comm.all_params_op_finished_signal.emit()
|
||
|
||
def log(self, message, level=logging.INFO):
|
||
color_map = {logging.ERROR: "#F56565", logging.WARNING: "#ECC94B", logging.INFO: "#48BB78",
|
||
logging.DEBUG: "#A0AEC0"}
|
||
formatted_message = f'<span style="color: {color_map.get(level, "#FFFFFF")};">{message}</span>'
|
||
self.log_text.append(formatted_message);
|
||
logger.log(level, message)
|
||
|
||
def generate_video_from_config(self, image_url: str, template: str, config: ImageConfig, duplicate: int):
|
||
url = f"{HOST}/webhook/glam-video-generation";
|
||
headers = {"Content-Type": "application/json"}
|
||
data = {"product": config.product, "scene": config.scene, "model": config.model, "image": image_url,
|
||
"duplicate": duplicate, "template": template}
|
||
for attempt in range(3):
|
||
try:
|
||
self.comm.log_signal.emit(f"调用API (模板: {template}, 尝试: {attempt + 1}/3)...", logging.INFO)
|
||
response = requests.post(url, headers=headers, json=data, timeout=600);
|
||
response.raise_for_status()
|
||
result = response.json()
|
||
if isinstance(result, list) and all("data" in item for item in result):
|
||
return [i["data"] for i in result]
|
||
else:
|
||
self.comm.log_signal.emit(f"API返回错误: {result.get('error', '未知API错误')}",
|
||
logging.ERROR);
|
||
return None
|
||
except requests.exceptions.RequestException as e:
|
||
self.comm.log_signal.emit(f"API请求失败 (尝试 {attempt + 1}): {e}", logging.WARNING)
|
||
if attempt < 2:
|
||
time.sleep(5)
|
||
else:
|
||
self.comm.log_signal.emit(f"API请求最终失败 (模板: {template})", logging.ERROR);
|
||
return None
|
||
return None
|
||
|
||
def get_intelligent_params(self):
|
||
if self.is_getting_single_param or self.is_generating: return
|
||
if not (current_item := self.image_list_widget.currentItem()): QMessageBox.information(self, "提示",
|
||
"请先在右侧列表中选择一张图片。"); return
|
||
if not (config := self.get_config_by_uid(current_item.data(Qt.UserRole))): return
|
||
self.is_getting_single_param = True;
|
||
self.add_button.setDisabled(True);
|
||
self.remove_button.setDisabled(True);
|
||
[b.setDisabled(True) for b in self.param_buttons]
|
||
config.param_status = ParamStatus.PROCESSING;
|
||
self.populate_param_view(config);
|
||
self.toggle_param_view_state(True)
|
||
self.update_status(f"正在为 {os.path.basename(config.path)} 智能获取参数...");
|
||
threading.Thread(target=self._get_params_worker, args=(config,), daemon=True).start()
|
||
|
||
def on_single_param_op_finished(self):
|
||
self.is_getting_single_param = False;
|
||
self.add_button.setDisabled(False);
|
||
self.remove_button.setDisabled(False)
|
||
self.toggle_param_view_state(True);
|
||
self.update_status("就绪")
|
||
|
||
def get_all_intelligent_params(self):
|
||
if self.is_getting_single_param or self.is_generating: return
|
||
if not self.image_configs: QMessageBox.information(self, "提示", "任务列表为空,请先添加图片。"); return
|
||
self.toggle_batch_ui_lock(True);
|
||
self.progress_dialog.show();
|
||
self.log("--- 开始一键获取所有图片参数 ---", logging.INFO)
|
||
threading.Thread(target=self._get_all_params_worker, args=(self.image_configs[:],), daemon=True).start()
|
||
|
||
def on_all_params_op_finished(self):
|
||
self.toggle_batch_ui_lock(False);
|
||
self.progress_dialog.hide();
|
||
self.log("--- 所有图片参数获取完成 ---", logging.INFO)
|
||
if self.image_list_widget.currentItem(): self.on_current_image_changed(self.image_list_widget.currentItem(),
|
||
None)
|
||
|
||
def on_params_received(self, uid: str, params: dict):
|
||
if config := self.get_config_by_uid(uid):
|
||
config.product = params.get("product", config.product);
|
||
config.scene = params.get("scene", config.scene);
|
||
config.model = params.get("model", config.model)
|
||
config.param_status = ParamStatus.DONE
|
||
if self.current_config_uid == uid: self.populate_param_view(config); self.toggle_param_view_state(True)
|
||
self.log(f"图片 '{os.path.basename(config.path)}' 的智能参数已更新。", logging.INFO)
|
||
|
||
def on_param_fetch_failed(self, uid: str):
|
||
if config := self.get_config_by_uid(uid):
|
||
config.param_status = ParamStatus.FAILED
|
||
if self.current_config_uid == uid: self.populate_param_view(config); self.toggle_param_view_state(True)
|
||
self.log(f"图片 '{os.path.basename(config.path)}' 的参数获取失败。", logging.WARNING)
|
||
|
||
def update_status(self, message):
|
||
self.status_bar.setText(message)
|
||
|
||
def select_output_path(self):
|
||
if dir_path := QFileDialog.getExistingDirectory(self, "选择输出文件夹"): self.output_input.setText(
|
||
dir_path); self.log(f"已选择输出路径: {dir_path}", logging.INFO)
|
||
|
||
def upload_to_s3(self, file_path):
|
||
self.comm.log_signal.emit(f"上传图片 {os.path.basename(file_path)}...", logging.INFO)
|
||
if not all([AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, S3_BUCKET]): self.comm.log_signal.emit(
|
||
"S3配置不完整,无法上传。", logging.ERROR); return None
|
||
try:
|
||
import mimetypes
|
||
mime_type = mimetypes.guess_type(file_path)[0]
|
||
headers = {
|
||
'accept': 'application/json',
|
||
'Mime-Type': mime_type,
|
||
}
|
||
|
||
files = {
|
||
'file': (os.path.basename(file_path), open(file_path, 'rb'), mime_type),
|
||
}
|
||
|
||
response = requests.post(
|
||
'https://bowongai-prod--text-video-agent-fastapi-app.modal.run/api/file/upload/s3',
|
||
headers=headers,
|
||
files=files,
|
||
)
|
||
self.comm.log_signal.emit(f"图片上传成功: {response.json()}", logging.INFO);
|
||
return response.json()['data']
|
||
except Exception as e:
|
||
self.comm.log_signal.emit(f"图片上传失败: {e}", logging.ERROR);
|
||
return None
|
||
|
||
def download_video(self, video_url, template, source_image_path):
|
||
image_basename = os.path.basename(source_image_path);
|
||
self.comm.status_signal.emit(f"下载视频 (模板: {template}, 源: {image_basename})...")
|
||
try:
|
||
response = requests.get(video_url, stream=True, timeout=300)
|
||
response.raise_for_status()
|
||
base_name, output_dir = os.path.splitext(image_basename)[0], self.output_input.text()
|
||
num = 1
|
||
safe_template_name = re.sub(r'[\\/*?:"<>|]', '_', template)
|
||
while True:
|
||
output_path = "/".join([output_dir, f"{base_name}_{safe_template_name}_{num}.mp4"])
|
||
if not os.path.exists(output_path): break
|
||
num += 1
|
||
with open(output_path, 'wb') as f:
|
||
for chunk in response.iter_content(chunk_size=8192): f.write(chunk)
|
||
self.comm.log_signal.emit(f"视频下载完成: {output_path}", logging.INFO)
|
||
# self.comm.preview_signal.emit(output_path, image_basename)
|
||
except Exception as e:
|
||
self.comm.log_signal.emit(f"视频下载失败 ({video_url}): {e}", logging.ERROR)
|
||
|
||
def show_preview_box(self, output_path, source_image_basename):
|
||
msg_box = QMessageBox(self)
|
||
msg_box.setWindowTitle("下载完成")
|
||
msg_box.setText(f"源于 {source_image_basename} 的视频已保存到:\n{output_path}")
|
||
msg_box.setStandardButtons(QMessageBox.Open | QMessageBox.Ok)
|
||
open_button = msg_box.button(QMessageBox.Open)
|
||
open_button.setText("打开文件夹")
|
||
if msg_box.exec() == QMessageBox.Open:
|
||
output_dir = os.path.dirname(output_path)
|
||
try:
|
||
if sys.platform == "win32":
|
||
os.startfile(output_dir)
|
||
elif sys.platform == "darwin":
|
||
os.system(f'open "{output_dir}"')
|
||
else:
|
||
os.system(f'xdg-open "{output_dir}"')
|
||
except Exception as e:
|
||
self.log(f"无法打开文件夹 {output_dir}: {e}", logging.ERROR)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app = QApplication(sys.argv)
|
||
window = VideoGeneratorGUI()
|
||
window.show()
|
||
sys.exit(app.exec())
|