GUI_Utils/n8n_video_gen_gui.py

1044 lines
55 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = "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"
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())