Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35f04f746b | |||
| 8ea3e26ab6 | |||
| 769590a525 | |||
| 3df88c48fd | |||
| 6ab953ae2a | |||
| 73294c6440 | |||
| c96b6de053 | |||
| 02660b063c | |||
| fd977991bd | |||
| ce9da8cbfe | |||
| e9128449ef | |||
| 90ec76ce92 |
@@ -0,0 +1,42 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""viral_video_param_guides 表:运营可配置的人设/结构/营销目的/目标客群/语言指导文案(PR3 P2-h)。
|
||||
|
||||
Revision ID: 109_add_viral_video_param_guides
|
||||
Revises: 108_storyboard_v4_multivoice
|
||||
Create Date: 2026-10-12
|
||||
|
||||
表用途:
|
||||
- 替代 worker 中 ~130 行硬编码 if/elif mapping;
|
||||
- 5 类 guide_type: persona / structure / marketing_purpose / target_customer / language;
|
||||
- 启动时由 seed_viral_video_param_guides.py 灌入代码默认值,运营可在后台改,30s TTL 缓存自动热加载。
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "109_add_viral_video_param_guides"
|
||||
down_revision = "108_storyboard_v4_multivoice"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS viral_video_param_guides (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guide_type VARCHAR(32) NOT NULL,
|
||||
guide_key VARCHAR(64) NOT NULL,
|
||||
guide_text TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (guide_type, guide_key)
|
||||
)
|
||||
""")
|
||||
op.execute("""
|
||||
CREATE INDEX IF NOT EXISTS ix_viral_video_param_guides_type_key
|
||||
ON viral_video_param_guides (guide_type, guide_key)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP INDEX IF EXISTS ix_viral_video_param_guides_type_key")
|
||||
op.execute("DROP TABLE IF EXISTS viral_video_param_guides")
|
||||
@@ -138,27 +138,6 @@
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid #f0f0f5;
|
||||
gap: 8px;
|
||||
}
|
||||
.vv-section.collapsed .vv-section-head {
|
||||
border-bottom: none;
|
||||
}
|
||||
.vv-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.vv-step-badge {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
@@ -310,11 +289,6 @@
|
||||
|
||||
/* antd Select dropdown light */
|
||||
.vv-page .ant-select-dropdown,
|
||||
.vv-select-dropdown {
|
||||
background: #fff !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 4px !important;
|
||||
}
|
||||
.vv-page .ant-select-item {
|
||||
color: #1f2937 !important;
|
||||
border-radius: 6px !important;
|
||||
@@ -655,10 +629,6 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.vv-douyin-input {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vv-ref-video-box {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -669,44 +639,6 @@
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.vv-audio-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.vv-audio-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 14px 8px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e8;
|
||||
border-radius: 8px;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.vv-audio-cell:hover:not(:disabled) {
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
.vv-audio-cell.selected,
|
||||
.vv-audio-cell.active {
|
||||
background: #f5f0ff;
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
.vv-audio-cell:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.vv-audio-ic {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.vv-selected-audio {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -857,12 +789,6 @@
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.vv-recog-v {
|
||||
color: #1f2937;
|
||||
}
|
||||
.vv-recog-meta {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* progress mini */
|
||||
.vv-progress-mini-inline {
|
||||
@@ -1002,11 +928,6 @@
|
||||
.vv-form-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.vv-form-hint {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.vv-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1026,29 +947,6 @@
|
||||
min-height: 260px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vv-copy-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.vv-copy-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
.vv-copy-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.vv-copy-tag-preview {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
border: 1px solid #fcd34d;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.vv-copy-loading {
|
||||
display: flex;
|
||||
@@ -1080,15 +978,6 @@
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.vv-copy-locked {
|
||||
background: #fafafe;
|
||||
border-color: #d8cafc;
|
||||
}
|
||||
.vv-copy-sub {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* ── Storyboard (linear doc style) ── */
|
||||
.vv-storyboard {
|
||||
display: flex;
|
||||
@@ -1789,84 +1678,10 @@
|
||||
}
|
||||
|
||||
/* intent (keyword chips) - if used */
|
||||
.vv-intent {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.vv-intent-title {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.vv-intent-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.vv-chip {
|
||||
padding: 4px 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e8;
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
}
|
||||
.vv-chip.picked {
|
||||
background: #f5f0ff;
|
||||
color: #7c3aed;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
|
||||
/* misc helpers */
|
||||
.vv-k {
|
||||
color: #6b7280;
|
||||
}
|
||||
.vv-v {
|
||||
color: #1f2937;
|
||||
}
|
||||
.vv-hint-yellow {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
color: #b45309;
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.vv-hint-gray {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Segmented tabs (legacy) */
|
||||
.vv-seg-tabs {
|
||||
display: inline-flex;
|
||||
background: #f5f6fa;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
.vv-seg-tab {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.vv-seg-tab:hover {
|
||||
color: #374151;
|
||||
background: #fff;
|
||||
}
|
||||
.vv-seg-tab.active {
|
||||
background: #f3f0ff;
|
||||
color: #7c3aed;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── v1.6 我的音色(默认主路径) ── */
|
||||
.vv-voice-section {
|
||||
@@ -2272,3 +2087,94 @@
|
||||
.vv-file-list {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
/* ── P2 新增 utility / 组件类 ── */
|
||||
/* 短剧模式提示条(放在 Step2 区域就地显示) */
|
||||
.vv-shortdrama-hint {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
background-color: #fff7e6;
|
||||
border: 1px solid #ffd591;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #d46b08;
|
||||
}
|
||||
|
||||
/* speaker 标签(主角 / 配角) */
|
||||
.vv-sb-speaker-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
margin: 0 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.vv-sb-speaker-tag-main {
|
||||
background-color: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
.vv-sb-speaker-tag-guest {
|
||||
background-color: #fff7e6;
|
||||
color: #fa8c16;
|
||||
}
|
||||
.vv-sb-speaker-main {
|
||||
color: #1890ff;
|
||||
}
|
||||
.vv-sb-speaker-guest {
|
||||
color: #fa8c16;
|
||||
}
|
||||
|
||||
/* 间距 / 排版 helpers */
|
||||
.vv-mt-4 {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.vv-mt-6 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.vv-mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.vv-mb-6 {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.vv-mr-6 {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.vv-fs-12 {
|
||||
font-size: 12px;
|
||||
}
|
||||
.vv-fs-13 {
|
||||
font-size: 13px;
|
||||
}
|
||||
.vv-fs-22 {
|
||||
font-size: 22px;
|
||||
}
|
||||
.vv-p-12 {
|
||||
padding: 12px;
|
||||
}
|
||||
.vv-text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.vv-flex-1 {
|
||||
flex: 1;
|
||||
}
|
||||
.vv-w-170 {
|
||||
width: 170px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.vv-inline-spinner {
|
||||
margin-right: 6px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.vv-progress-bar-sm {
|
||||
margin: 6px 0 4px;
|
||||
}
|
||||
|
||||
.vv-loading-ic {
|
||||
font-size: 36px;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ function emptyTask(id: string, title: string): TabTask {
|
||||
title,
|
||||
images: [],
|
||||
refVideo: null,
|
||||
douyinInput: "",
|
||||
voicePanel: null,
|
||||
refAudio: null,
|
||||
fusionLevel: "ai_full",
|
||||
@@ -176,7 +175,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
moveImg,
|
||||
clearImages,
|
||||
handleLocalRefVideo,
|
||||
handleDouyinUrl,
|
||||
removeRefVideo,
|
||||
handleVoiceFile,
|
||||
onCloneSuccess,
|
||||
@@ -208,8 +206,8 @@ const ViralVideoPage: React.FC = () => {
|
||||
try {
|
||||
const job = await getViralVideoJob(ev.job_id)
|
||||
onPollUpdate(job)
|
||||
} catch (e) {
|
||||
console.warn("[viral-video] WS终态主动拉取失败,等待HTTP轮询兜底", e)
|
||||
} catch {
|
||||
// WS终态拉取失败,等待HTTP轮询兜底
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -413,7 +411,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
<Step1UploadPanel
|
||||
images={task.images}
|
||||
refVideo={task.refVideo}
|
||||
douyinInput={task.douyinInput}
|
||||
voicePanel={task.voicePanel}
|
||||
refAudio={task.refAudio}
|
||||
voiceTipsOpen={task.voiceTipsOpen}
|
||||
@@ -435,11 +432,6 @@ const ViralVideoPage: React.FC = () => {
|
||||
onImagesSelected={appendImagesFromFiles}
|
||||
onAnalyze={handleAnalyzeImages}
|
||||
onPickLocalVideo={() => videoInputRef.current?.click()}
|
||||
onToggleDouyinPanel={() =>
|
||||
setTask({ voicePanel: task.voicePanel === "douyin" ? null : "douyin" })
|
||||
}
|
||||
onDouyinInputChange={(v) => setTask({ douyinInput: v })}
|
||||
onDouyinSubmit={handleDouyinUrl}
|
||||
onVideoSelected={handleLocalRefVideo}
|
||||
onRemoveRefVideo={removeRefVideo}
|
||||
onStyleStrengthChange={(v) => setTask({ styleStrength: v })}
|
||||
@@ -473,6 +465,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
viralStructure={task.viralStructure}
|
||||
marketingPurpose={task.marketingPurpose}
|
||||
duration={task.duration}
|
||||
copyError={task.copyError}
|
||||
onFusionLevelChange={(v) => setTask({ fusionLevel: v })}
|
||||
onUserCopyChange={(v) => setTask({ userCopy: v })}
|
||||
onParamChange={(patch) => setTask(patch)}
|
||||
@@ -529,31 +522,9 @@ const ViralVideoPage: React.FC = () => {
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* REVIEW 合规失败就地显示 */}
|
||||
{task.uiStep === "step2_copy_ready" && task.copyError && (
|
||||
<ErrorRetryBar label="文案审核未通过" message={task.copyError} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 短剧提示条 */}
|
||||
{task.marketingPurpose && task.marketingPurpose.includes("短剧") && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
marginBottom: 8,
|
||||
backgroundColor: "#fff7e6",
|
||||
border: "1px solid #ffd591",
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
color: "#d46b08",
|
||||
}}
|
||||
>
|
||||
💡 短剧模式将自动为对手戏角色分配配角音色
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CloneModal
|
||||
open={cloneModalOpen}
|
||||
onClose={() => setCloneModalOpen(false)}
|
||||
|
||||
@@ -152,8 +152,7 @@ export default function AssetPickerModal({
|
||||
<div className="vv-modal-body">
|
||||
<div className="vv-asset-search">
|
||||
<select
|
||||
className="vv-input"
|
||||
style={{ width: 170, flex: "0 0 auto" }}
|
||||
className="vv-input vv-w-170"
|
||||
value={libraryId}
|
||||
onChange={(e) => setLibraryId(e.target.value)}
|
||||
disabled={loadingLibs || libraries.length === 0}
|
||||
@@ -201,8 +200,8 @@ export default function AssetPickerModal({
|
||||
</div>
|
||||
{kind === "voice" ? (
|
||||
<>
|
||||
<div style={{ marginTop: 8, fontSize: 13 }}>暂无配音素材</div>
|
||||
<div style={{ marginTop: 4, fontSize: 12, color: "#9ca3af" }}>
|
||||
<div className="vv-mt-8 vv-fs-13">暂无配音素材</div>
|
||||
<div className="vv-muted vv-mt-4 vv-fs-12">
|
||||
请先在「配音/我的音色」中上传音频文件,或在素材库管理中添加
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -106,10 +106,10 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||
const GENDER_LABEL = (v: PresetVoice) => {
|
||||
if (v.gender_label) return v.gender_label
|
||||
const g = v.gender
|
||||
if (g === "female") return "女声·女声"
|
||||
if (g === "male") return "男声·男声"
|
||||
if (g === "child") return "童声·童声"
|
||||
return "性别未标注·其他"
|
||||
if (g === "female") return "女声"
|
||||
if (g === "male") return "男声"
|
||||
if (g === "child") return "童声"
|
||||
return "其他"
|
||||
}
|
||||
|
||||
const AVATAR_BG = (gender?: string) => {
|
||||
@@ -303,7 +303,7 @@ const PresetVoicePickerModal: React.FC<Props> = ({
|
||||
{v.avatar_url ? (
|
||||
<img src={v.avatar_url} alt={v.name} />
|
||||
) : (
|
||||
<UserOutlined style={{ fontSize: 22 }} />
|
||||
<UserOutlined className="vv-fs-22" />
|
||||
)}
|
||||
</div>
|
||||
<div className="vv-voice-card-name" title={v.name}>
|
||||
|
||||
@@ -5,9 +5,7 @@ import {
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
CloseOutlined,
|
||||
LinkOutlined,
|
||||
} from "@ant-design/icons"
|
||||
import { Input } from "antd"
|
||||
import { VVSpinner } from "./VVSpinner"
|
||||
import { renderMarkdown } from "../utils/renderMarkdown"
|
||||
import { CheckCircleFilled } from "@ant-design/icons"
|
||||
@@ -21,7 +19,6 @@ import type { AssetItem } from "@/api/assets"
|
||||
export interface Step1UploadPanelProps {
|
||||
images: UploadedImage[]
|
||||
refVideo: RefVideo
|
||||
douyinInput: string
|
||||
voicePanel: VoicePanel
|
||||
refAudio: RefAudio
|
||||
voiceTipsOpen: boolean
|
||||
@@ -45,9 +42,6 @@ export interface Step1UploadPanelProps {
|
||||
onAnalyze: () => void
|
||||
// callbacks - ref video
|
||||
onPickLocalVideo: () => void
|
||||
onToggleDouyinPanel: () => void
|
||||
onDouyinInputChange: (v: string) => void
|
||||
onDouyinSubmit: () => void
|
||||
onVideoSelected: (file: File) => void
|
||||
onRemoveRefVideo: () => void
|
||||
onStyleStrengthChange: (v: StyleStrength) => void
|
||||
@@ -94,10 +88,7 @@ function RecognitionBlock({
|
||||
return (
|
||||
<div className="vv-recog">
|
||||
<div className="vv-recog-title">
|
||||
<VVSpinner
|
||||
color="#7c3aed"
|
||||
style={{ marginRight: 6, display: "inline-block", verticalAlign: "middle" }}
|
||||
/>
|
||||
<VVSpinner color="#7c3aed" className="vv-inline-spinner" />
|
||||
识别描述汇览
|
||||
</div>
|
||||
<div className="vv-muted">AI 正在识别画面…</div>
|
||||
@@ -240,9 +231,6 @@ export const Step1UploadPanel: React.FC<Step1UploadPanelProps> = (p) => {
|
||||
<button className="vv-action-btn" onClick={p.onPickLocalVideo}>
|
||||
<UploadOutlined /> 本地上传视频
|
||||
</button>
|
||||
<button className="vv-action-btn" onClick={p.onToggleDouyinPanel}>
|
||||
<LinkOutlined /> 抖音链接识别
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
ref={p.videoInputRef}
|
||||
@@ -254,24 +242,6 @@ export const Step1UploadPanel: React.FC<Step1UploadPanelProps> = (p) => {
|
||||
e.target.value = ""
|
||||
}}
|
||||
/>
|
||||
{p.voicePanel === "douyin" && !p.refVideo && (
|
||||
<div className="vv-douyin-input">
|
||||
<Input
|
||||
placeholder="粘贴抖音分享链接,例:https://v.douyin.com/xxxxx/"
|
||||
value={p.douyinInput}
|
||||
onChange={(e) => p.onDouyinInputChange(e.target.value)}
|
||||
onPressEnter={p.onDouyinSubmit}
|
||||
suffix={
|
||||
<button className="vv-link-btn" onClick={p.onDouyinSubmit}>
|
||||
识别
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="vv-muted" style={{ marginTop: 4, fontSize: 11 }}>
|
||||
粘贴后自动解析下载参考视频(敬请期待)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{p.refVideo && (
|
||||
<div className="vv-ref-video-box">
|
||||
<video
|
||||
@@ -279,10 +249,10 @@ export const Step1UploadPanel: React.FC<Step1UploadPanelProps> = (p) => {
|
||||
controls
|
||||
className="vv-ref-video-el"
|
||||
/>
|
||||
<div className="vv-file-info" style={{ marginTop: 6 }}>
|
||||
<div className="vv-file-info vv-mt-6">
|
||||
<div className="vv-file-name">{p.refVideo.name}</div>
|
||||
{p.refVideo.uploading && (
|
||||
<div className="vv-progress-bar" style={{ margin: "6px 0 4px" }}>
|
||||
<div className="vv-progress-bar vv-progress-bar-sm">
|
||||
<div className="vv-progress-fill" style={{ width: `${p.refVideo.pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
@@ -292,9 +262,7 @@ export const Step1UploadPanel: React.FC<Step1UploadPanelProps> = (p) => {
|
||||
</button>
|
||||
{p.refVideo.ossUrl && (
|
||||
<div className="vv-mt-8">
|
||||
<div className="vv-label" style={{ marginBottom: 6 }}>
|
||||
风格借鉴强度
|
||||
</div>
|
||||
<div className="vv-label vv-mb-6">风格借鉴强度</div>
|
||||
<div className="vv-pill-row">
|
||||
{STYLE_STRENGTHS.map((s) => (
|
||||
<button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Select } from "antd"
|
||||
import { FUSION_LEVELS } from "@/api/viral-video/types"
|
||||
import type { FusionLevel } from "@/api/viral-video/types"
|
||||
import { VVSpinner } from "./VVSpinner"
|
||||
import { ErrorRetryBar } from "./ErrorRetryBar"
|
||||
import { StoryboardCard } from "./StoryboardCard"
|
||||
import type { SbDoc, StoryboardEditor } from "../hooks/useStoryboardEditor"
|
||||
import type { UploadedImage, UIStep, ViralVideoJob } from "../types"
|
||||
@@ -52,6 +53,7 @@ export interface Step2CopyPanelProps {
|
||||
images: UploadedImage[]
|
||||
locked: boolean
|
||||
job: ViralVideoJob | null
|
||||
copyError?: string
|
||||
}
|
||||
|
||||
export const Step2CopyPanel: React.FC<Step2CopyPanelProps> = (p) => {
|
||||
@@ -181,6 +183,16 @@ export const Step2CopyPanel: React.FC<Step2CopyPanelProps> = (p) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 短剧模式提示 */}
|
||||
{p.marketingPurpose && p.marketingPurpose.includes("短剧") && (
|
||||
<div className="vv-shortdrama-hint">
|
||||
💡 短剧模式已选中,对手戏角色将自动分配配角音色
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文案审核错误(持久显示) */}
|
||||
{p.copyError && <ErrorRetryBar label="文案审核未通过" message={p.copyError} />}
|
||||
|
||||
{/* 分镜结果区 / 流式预览 */}
|
||||
<StoryboardCard
|
||||
uiStep={p.uiStep}
|
||||
|
||||
@@ -154,7 +154,7 @@ export const Step3VideoPanel: React.FC<Step3VideoPanelProps> = (p) => {
|
||||
) : isGenerating ? (
|
||||
<div className="vv-preview-placeholder">
|
||||
<div className="ph-icon">
|
||||
<LoadingOutlined style={{ fontSize: 36, color: "#7c3aed" }} />
|
||||
<LoadingOutlined className="vv-loading-ic" />
|
||||
</div>
|
||||
<div className="ph-txt">
|
||||
AI 正在生成视频中…
|
||||
@@ -162,9 +162,7 @@ export const Step3VideoPanel: React.FC<Step3VideoPanelProps> = (p) => {
|
||||
预计 1-3 分钟,请勿关闭页面
|
||||
</div>
|
||||
{getJobPhaseMessage(p.job) && (
|
||||
<div className="vv-muted" style={{ marginTop: 6, fontSize: 12 }}>
|
||||
{getJobPhaseMessage(p.job)}
|
||||
</div>
|
||||
<div className="vv-muted vv-mt-6 vv-fs-12">{getJobPhaseMessage(p.job)}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -39,7 +39,7 @@ function renderVoiceoverWithSpeakers(text: string) {
|
||||
const isMain = MAIN_SPEAKERS.includes(speaker)
|
||||
elements.push(
|
||||
<span key={i}>
|
||||
<strong style={{ color: isMain ? "#1890ff" : "#fa8c16" }}>{speaker}</strong>
|
||||
<strong className={isMain ? "vv-sb-speaker-main" : "vv-sb-speaker-guest"}>{speaker}</strong>
|
||||
<span>{segText}</span>
|
||||
</span>,
|
||||
)
|
||||
@@ -111,7 +111,7 @@ export const StoryboardCard: React.FC<StoryboardCardProps> = ({
|
||||
if (uiStep === "step2_copy_ready" || uiStep === "step3_generating" || uiStep === "step3_done") {
|
||||
return (
|
||||
<div className="vv-copy-box vv-copy-loading">
|
||||
<span style={{ color: "#9ca3af" }}>暂无分镜数据</span>
|
||||
<span className="vv-muted">暂无分镜数据</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,18 +51,7 @@ export const StoryboardShotItem: React.FC<StoryboardShotItemProps> = ({
|
||||
{/* 说话人标签(有 speaker 时展示,纯展示不加编辑) */}
|
||||
{shot.speaker && (
|
||||
<span
|
||||
className="vv-sb-speaker-tag"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
marginRight: 8,
|
||||
marginLeft: 8,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
backgroundColor: isMainSpeaker ? "#e6f7ff" : "#fff7e6",
|
||||
color: isMainSpeaker ? "#1890ff" : "#fa8c16",
|
||||
}}
|
||||
className={`vv-sb-speaker-tag ${isMainSpeaker ? "vv-sb-speaker-tag-main" : "vv-sb-speaker-tag-guest"}`}
|
||||
>
|
||||
{shot.speaker}
|
||||
</span>
|
||||
|
||||
@@ -143,8 +143,8 @@ export const VoicePickerSection: React.FC<VoicePickerSectionProps> = (p) => {
|
||||
/>
|
||||
{p.refAudio && (
|
||||
<div className="vv-selected-audio">
|
||||
<span style={{ marginRight: 6 }}>🔊</span>
|
||||
<span style={{ flex: 1 }}>
|
||||
<span className="vv-mr-6">🔊</span>
|
||||
<span className="vv-flex-1">
|
||||
已选:
|
||||
{p.refAudio.source === "my_voice"
|
||||
? `我的音色 · ${p.refAudio.name}`
|
||||
@@ -168,9 +168,7 @@ export const VoicePickerSection: React.FC<VoicePickerSectionProps> = (p) => {
|
||||
</button>
|
||||
</div>
|
||||
{p.voiceAssets.length === 0 ? (
|
||||
<div className="vv-muted" style={{ padding: 12, textAlign: "center" }}>
|
||||
暂无已上传的配音文件
|
||||
</div>
|
||||
<div className="vv-muted vv-text-center vv-p-12">暂无已上传的配音文件</div>
|
||||
) : (
|
||||
<div className="vv-voice-list">
|
||||
{p.voiceAssets.map((v) => (
|
||||
|
||||
@@ -58,7 +58,6 @@ function emptyTask(id: string, title: string): TabTask {
|
||||
title,
|
||||
images: [],
|
||||
refVideo: null,
|
||||
douyinInput: "",
|
||||
voicePanel: null,
|
||||
refAudio: null,
|
||||
fusionLevel: "ai_full",
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface MediaActions {
|
||||
removeImg: (localId: string) => void
|
||||
moveImg: (idx: number, dir: -1 | 1) => void
|
||||
handleLocalRefVideo: (file: File) => void
|
||||
handleDouyinUrl: () => void
|
||||
removeRefVideo: () => void
|
||||
handleVoiceFile: (file: File) => void
|
||||
onCloneSuccess: (v: VoiceClone | { id?: string; name?: string; voice_id?: string | null }) => void
|
||||
@@ -221,20 +220,6 @@ export function useViralVideoMedia(opts: UseViralVideoMediaOptions): MediaAction
|
||||
[setTask, uploadFile],
|
||||
)
|
||||
|
||||
const handleDouyinUrl = () => {
|
||||
const url = task.douyinInput.trim()
|
||||
if (!url) {
|
||||
message.warning("请粘贴抖音分享链接")
|
||||
return
|
||||
}
|
||||
if (!/(douyin\.com|iesdouyin\.com)/.test(url)) {
|
||||
message.warning("请输入有效的抖音链接")
|
||||
return
|
||||
}
|
||||
message.info("抖音链接识别功能即将上线,当前请使用本地上传参考视频")
|
||||
setTask({ douyinInput: "" })
|
||||
}
|
||||
|
||||
const removeRefVideo = () => {
|
||||
setTask((t) => {
|
||||
if (t.refVideo?.url.startsWith("blob:")) URL.revokeObjectURL(t.refVideo.url)
|
||||
@@ -350,7 +335,6 @@ export function useViralVideoMedia(opts: UseViralVideoMediaOptions): MediaAction
|
||||
removeImg,
|
||||
moveImg,
|
||||
handleLocalRefVideo,
|
||||
handleDouyinUrl,
|
||||
removeRefVideo,
|
||||
handleVoiceFile,
|
||||
onCloneSuccess,
|
||||
|
||||
@@ -61,7 +61,7 @@ export function useViralVideoPolling(
|
||||
failCountRef.current += 1
|
||||
if (stoppedRef.current) return
|
||||
if (failCountRef.current >= 20) {
|
||||
console.warn("[viral-video] 轮询连续失败 20 次,停止轮询,请刷新页面重试")
|
||||
// 轮询连续失败 20 次,停止轮询
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -33,14 +33,13 @@ export type UploadedImage = {
|
||||
}
|
||||
|
||||
export type RefVideo = {
|
||||
source: "local" | "douyin"
|
||||
source: "local"
|
||||
url: string
|
||||
name: string
|
||||
uploading: boolean
|
||||
pct: number
|
||||
ossUrl?: string
|
||||
file?: File
|
||||
douyinUrl?: string
|
||||
} | null
|
||||
|
||||
export type RefAudio = {
|
||||
@@ -52,7 +51,7 @@ export type RefAudio = {
|
||||
ttsVoiceId?: string
|
||||
} | null
|
||||
|
||||
export type VoicePanel = "my_voices" | "preset" | "upload" | "library" | "record" | "douyin" | null
|
||||
export type VoicePanel = "my_voices" | "preset" | "upload" | "library" | "record" | null
|
||||
|
||||
// ── 分镜脚本数据模型(新后端 copy_result 结构) ──
|
||||
export interface StoryboardShot {
|
||||
@@ -82,7 +81,6 @@ export type TabTask = {
|
||||
// 素材
|
||||
images: UploadedImage[]
|
||||
refVideo: RefVideo
|
||||
douyinInput: string
|
||||
voicePanel: VoicePanel
|
||||
refAudio: RefAudio
|
||||
// STEP2 参数
|
||||
|
||||
@@ -47,12 +47,19 @@ from packages.application.viral_video.constants import (
|
||||
get_duration_spec,
|
||||
)
|
||||
from packages.application.viral_video.constants import get_expected_shot_count as _const_expected_shot_count
|
||||
from packages.application.viral_video.param_guides import get_param_guide
|
||||
from packages.application.viral_video.prompts import COMPLIANCE_BLACKLIST
|
||||
from packages.domain.viral_video import (
|
||||
STAGE_LABELS,
|
||||
ViralVideoComplianceError,
|
||||
ViralVideoError,
|
||||
ViralVideoJob,
|
||||
ViralVideoLLMError,
|
||||
ViralVideoResourceError,
|
||||
ViralVideoStage,
|
||||
ViralVideoStatus,
|
||||
ViralVideoTimeoutError,
|
||||
ViralVideoUpstreamError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -516,6 +523,49 @@ def with_viral_job(
|
||||
return func(job, repo, session, *rest_args, **kwargs)
|
||||
except Retry:
|
||||
raise
|
||||
except ViralVideoError as e:
|
||||
# PR3 P2(b): 业务异常走友好 message,不打堆栈
|
||||
logger.warning(
|
||||
"[viral] task %s business_error code=%s user_msg=%s detail=%s",
|
||||
func.__name__,
|
||||
e.code,
|
||||
e.user_message,
|
||||
e.detail,
|
||||
)
|
||||
err_user_msg = e.user_message
|
||||
err_stage = (
|
||||
getattr(e, "stage", "") if isinstance(e, (ViralVideoTimeoutError, ViralVideoLLMError)) else ""
|
||||
)
|
||||
# refund_on_failure: 业务异常同样按阶段3退款
|
||||
if refund_on_failure and job is None:
|
||||
try:
|
||||
if session is not None:
|
||||
_repo_safe = SQLAlchemyViralVideoJobRepository(session)
|
||||
_job_safe = _repo_safe.get(job_id)
|
||||
if _job_safe is not None and float(getattr(_job_safe, "credits_prepaid", 0) or 0) > 0:
|
||||
_try_refund_viral_video(_job_safe)
|
||||
_repo_safe.update(_job_safe)
|
||||
except Exception:
|
||||
logger.exception("[viral] task %s 兜底退款异常", func.__name__)
|
||||
elif refund_on_failure and job is not None:
|
||||
try:
|
||||
if float(getattr(job, "credits_prepaid", 0) or 0) > 0:
|
||||
_try_refund_viral_video(job)
|
||||
except Exception:
|
||||
logger.exception("[viral] task %s 兜底退款异常", func.__name__)
|
||||
_err_job = job
|
||||
try:
|
||||
if job is None and session is not None:
|
||||
_repo_tmp = SQLAlchemyViralVideoJobRepository(session)
|
||||
_err_job = _repo_tmp.get(job_id)
|
||||
if _err_job is not None and getattr(_err_job, "current_stage", None) and not err_stage:
|
||||
err_stage = _err_job.current_stage
|
||||
except Exception:
|
||||
pass
|
||||
_mark_failed_and_notify(
|
||||
job_id, session, None, _err_job if _err_job is not None else None, err_user_msg, err_stage
|
||||
)
|
||||
return {"ok": False, "job_id": job_id, "error": err_user_msg, "code": e.code}
|
||||
except Exception as e:
|
||||
logger.exception("[viral] task %s failed: %s", func.__name__, e)
|
||||
# refund_on_failure: 阶段3(render)失败兜底退款
|
||||
@@ -749,22 +799,16 @@ def _step_video_analysis(job: ViralVideoJob) -> dict | None:
|
||||
style_guide = analyze_video_style(video_url)
|
||||
return style_guide if isinstance(style_guide, dict) else None
|
||||
except ImportError as e:
|
||||
logger.info("[爆款视频] video_analyzer 模块未就绪(%s),使用占位风格分析", e)
|
||||
return {
|
||||
"cut_speed": "medium",
|
||||
"transition": "cross_dissolve",
|
||||
"energy": "medium",
|
||||
"color_grade": "neutral",
|
||||
"narrative": False,
|
||||
"source": "placeholder",
|
||||
}
|
||||
logger.info("[爆款视频] video_analyzer 模块未就绪(%s),跳过参考视频风格分析", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("[爆款视频] 视频风格分析失败: %s", e)
|
||||
return {"error": str(e), "source": "failed"}
|
||||
|
||||
|
||||
# persona_id -> 文案风格指导(仅用于给脚本生成 LLM 的风格提示)
|
||||
_PERSONA_STYLE_GUIDE: dict[str, str] = {
|
||||
# ── PR3 P2(h): 参数指导优先查 DB(viral_video_param_guides),代码默认值兜底 ──
|
||||
# 代码默认值保证 DB 未 seed / DB 异常时不阻断流程
|
||||
_DEFAULT_PERSONA_HINTS: dict[str, str] = {
|
||||
"宝妈": "温柔贴心,多从带娃和家庭实用角度分享,语气亲切有共鸣",
|
||||
"店主": "热情实在,像当面招呼客人,突出靠谱和实在优惠",
|
||||
"专业顾问": "专业可信,讲清原理和效果,用事实打消顾虑",
|
||||
@@ -779,19 +823,7 @@ _PERSONA_STYLE_GUIDE: dict[str, str] = {
|
||||
"育儿师": "科学育儿角度,温柔坚定,给具体可操作的建议",
|
||||
}
|
||||
|
||||
|
||||
def _persona_style_hint(persona_id: str) -> str:
|
||||
"""根据 persona_id 查文案风格指导;未命中/空值返回通用提示。"""
|
||||
pid = (persona_id or "").strip()
|
||||
if pid in _PERSONA_STYLE_GUIDE:
|
||||
return f"【人设风格:{pid}】{_PERSONA_STYLE_GUIDE[pid]}"
|
||||
if pid:
|
||||
# 前端传了自由值,照直提示,不阻塞
|
||||
return f"【人设风格:{pid}】按该人设的口吻、话术习惯组织口播和出镜动作"
|
||||
return "【人设风格:未指定】亲切自然、像朋友分享好物"
|
||||
|
||||
|
||||
_VIRAL_STRUCTURE_GUIDE: dict[str, str] = {
|
||||
_DEFAULT_VIRAL_STRUCTURE_HINTS: dict[str, str] = {
|
||||
"反差破局+亮明观点+还原现状": "开头3秒用反差/痛点钩子抓注意力,中段亮出核心卖点或观点,结尾还原真实到店/使用场景引导行动",
|
||||
"痛点切入+方案展示+效果对比": "开头直击用户痛点场景,中间展示产品/服务解决方案,结尾用前后对比强化效果",
|
||||
"故事引入+产品种草+行动引导": "用一个真实小故事/案例引入,自然过渡到产品种草,结尾明确引导用户下一步行动",
|
||||
@@ -800,81 +832,92 @@ _VIRAL_STRUCTURE_GUIDE: dict[str, str] = {
|
||||
}
|
||||
|
||||
|
||||
def _db_guide(guide_type: str, guide_key: str) -> str | None:
|
||||
"""读 DB 指导文本;DB 异常返回 None(不抛异常、不打刷屏日志)。"""
|
||||
try:
|
||||
return get_param_guide(guide_type, guide_key)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _persona_style_hint(persona_id: str) -> str:
|
||||
"""根据 persona_id 查文案风格指导;未命中/空值返回通用提示。"""
|
||||
pid = (persona_id or "").strip()
|
||||
if not pid:
|
||||
return "【人设风格:未指定】亲切自然、像朋友分享好物"
|
||||
text = _db_guide("persona", pid) or _DEFAULT_PERSONA_HINTS.get(pid)
|
||||
if text:
|
||||
return f"【人设风格:{pid}】{text}"
|
||||
return f"【人设风格:{pid}】按该人设的口吻、话术习惯组织口播和出镜动作"
|
||||
|
||||
|
||||
def _viral_structure_hint(structure: str) -> str:
|
||||
"""根据 viral_structure 映射具体写作指导;未命中返回通用提示。"""
|
||||
s = (structure or "").strip()
|
||||
if s in _VIRAL_STRUCTURE_GUIDE:
|
||||
return f"【爆款结构:{s}】{_VIRAL_STRUCTURE_GUIDE[s]}"
|
||||
if s:
|
||||
return f"【爆款结构:{s}】按该结构编排内容节奏和叙事逻辑"
|
||||
return "【爆款结构:未指定】自由组织,保证开头有钩子、中段有卖点、结尾有行动引导"
|
||||
if not s:
|
||||
return "【爆款结构:未指定】自由组织,保证开头有钩子、中段有卖点、结尾有行动引导"
|
||||
text = _db_guide("structure", s) or _DEFAULT_VIRAL_STRUCTURE_HINTS.get(s)
|
||||
if text:
|
||||
return f"【爆款结构:{s}】{text}"
|
||||
return f"【爆款结构:{s}】按该结构编排内容节奏和叙事逻辑"
|
||||
|
||||
|
||||
def _marketing_purpose_hint(mp: str) -> str:
|
||||
"""根据营销目的提供创作指导 hint。
|
||||
|
||||
覆盖前端 20 个 PURPOSES,分 4 类指导:
|
||||
- 短剧类:多角色对话,格式"角色:台词",禁止旁白,角色间镜头切换
|
||||
- 口播类:第一人称对镜头说话,真人出镜感
|
||||
- 门店类:店主或探店博主出镜讲解+场景展示
|
||||
- 品牌类:主演出镜说话或角色对白,禁止上帝视角旁白
|
||||
"""
|
||||
"""根据营销目的提供创作指导 hint(关键词分类,DB 精确覆盖优先)。"""
|
||||
mp = (mp or "").strip()
|
||||
if not mp:
|
||||
return ""
|
||||
|
||||
# 精确匹配 DB 覆盖(运营可配置自定义营销目的)
|
||||
db_text = _db_guide("marketing_purpose", mp)
|
||||
if db_text:
|
||||
return db_text
|
||||
|
||||
# 短剧类(多角色对话)
|
||||
if any(k in mp for k in ["对话短剧", "反转短剧", "悬念短剧", "情绪短片"]):
|
||||
return "【短剧模式】必须双/多角色对话格式,台词用'角色:xxx'格式,镜头在角色间切换,禁止第三人称旁白解说。"
|
||||
|
||||
# 口播类(第一人称对镜头说话)
|
||||
if any(
|
||||
k in mp
|
||||
for k in ["口播带货", "促销转化", "功能演示", "痛点解决", "获客引流", "账号涨粉", "活动通知", "场景种草"]
|
||||
):
|
||||
return "【口播模式】第一人称对镜头说话,像真人出镜,有语气停顿和情绪,禁止第三人称旁白。"
|
||||
|
||||
# 门店类(店主/探店博主出镜)
|
||||
if any(k in mp for k in ["门店发现", "到店实录", "招牌体验", "同城团购"]):
|
||||
return "【门店模式】店主或探店博主出镜讲解+场景展示,第一人称或角色对白,禁止上帝视角旁白。"
|
||||
|
||||
# 品牌类(主演出镜或角色对白)
|
||||
if any(k in mp for k in ["品牌主张", "品牌故事", "生活方式", "创意概念"]):
|
||||
return "【品牌模式】主演出镜说话或角色对白,禁止上帝视角旁白,要有品牌调性和情感共鸣。"
|
||||
|
||||
# 未匹配的营销目的:通用 hint
|
||||
return "【通用模式】避免上帝视角旁白,优先第一人称或角色对话。"
|
||||
|
||||
|
||||
def _target_customer_hint(tc: str) -> str:
|
||||
"""根据目标客户提供台词风格指导。"""
|
||||
"""根据目标客户提供台词风格指导(关键词分类,DB 精确覆盖优先)。"""
|
||||
tc = (tc or "").strip()
|
||||
if not tc:
|
||||
return ""
|
||||
|
||||
# 年轻人
|
||||
db_text = _db_guide("target_customer", tc)
|
||||
if db_text:
|
||||
return db_text
|
||||
if any(k in tc for k in ["18-25", "年轻", "学生", "Z世代", "95后", "00后"]):
|
||||
return "【目标年轻客群】台词要活泼、有梗、节奏快,可用网络流行语。"
|
||||
|
||||
# 中年人
|
||||
if any(k in tc for k in ["30-45", "中年", "家庭", "宝妈", "职场"]):
|
||||
return "【目标中年客群】台词要实用、有共鸣,强调性价比和品质。"
|
||||
|
||||
# 老年人
|
||||
if any(k in tc for k in ["50+", "老年", "退休", "银发"]):
|
||||
return "【目标老年客群】台词要清晰、慢节奏,强调健康和实惠。"
|
||||
|
||||
# 高端
|
||||
if any(k in tc for k in ["高端", "商务", "精英", "白领"]):
|
||||
return "【目标高端客群】台词要专业、有格调,强调品质和身份。"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _language_hint(language: str) -> str:
|
||||
"""根据 language 代码返回语言提示。"""
|
||||
"""根据 language 代码返回语言提示(DB 精确覆盖优先,代码默认兜底)。"""
|
||||
lang = (language or "zh-CN").strip().lower()
|
||||
mapping = {
|
||||
db_text = _db_guide("language", lang)
|
||||
if db_text:
|
||||
return f"【语言:{lang}】{db_text}"
|
||||
_defaults: dict[str, str] = {
|
||||
"zh-cn": "使用标准普通话,口语化表达",
|
||||
"zh-tw": "使用台湾腔中文,语气温柔亲切",
|
||||
"zh-hk": "使用粤语风格中文表达",
|
||||
@@ -883,7 +926,7 @@ def _language_hint(language: str) -> str:
|
||||
"ja-jp": "使用日语,自然口语化",
|
||||
"ko-kr": "使用韩语,亲切自然",
|
||||
}
|
||||
hint = mapping.get(lang, "")
|
||||
hint = _defaults.get(lang, "")
|
||||
if hint:
|
||||
return f"【语言:{lang}】{hint}"
|
||||
if lang.startswith("zh"):
|
||||
@@ -988,25 +1031,15 @@ def _fallback_script(job: ViralVideoJob) -> dict:
|
||||
_brand_str = _brands[0] if _brands else ""
|
||||
_mp = (getattr(job, "marketing_purpose", "") or "").strip()
|
||||
|
||||
# 基于真实识别结果构建口播,不再用"这家店""一款好物"等通用表述
|
||||
# PR3 P2(c): 兜底口播只用真实 VLM 输入,诚实简短,让前端/用户一眼看出是兜底触发重试
|
||||
if job.user_copy_text:
|
||||
voiceover = job.user_copy_text
|
||||
elif _dominant == "store" and _name_str:
|
||||
voiceover = (
|
||||
f"今天带大家来{_name_str}看看,{_brand_str + ',' if _brand_str else ''}环境和服务都很赞,推荐来体验!"
|
||||
)
|
||||
elif _dominant == "store":
|
||||
voiceover = "今天带大家探店,这家店的环境和服务都很不错,推荐大家来体验一下。"
|
||||
elif _dominant == "product" and _name_str:
|
||||
voiceover = f"给大家分享{_brand_str + '家的' if _brand_str else ''}{_name_str},真心不错,推荐你们试试。"
|
||||
elif _dominant == "product":
|
||||
voiceover = "给大家分享一款我最近在用的好物,真的很不错,推荐你们也试试。"
|
||||
elif _dominant == "person":
|
||||
voiceover = "哈喽,今天给大家分享我的日常,简单舒适又好看,你们觉得怎么样?"
|
||||
elif _dominant == "scene" and _name_str:
|
||||
voiceover = f"带大家感受一下{_name_str},氛围感拉满,真的很适合打卡体验。"
|
||||
elif _brand_str and _name_str:
|
||||
voiceover = f"来看看{_brand_str}家的{_name_str}"
|
||||
elif _name_str:
|
||||
voiceover = f"来看看{_name_str}"
|
||||
else:
|
||||
voiceover = "带大家感受一下这个空间,氛围感拉满,真的很适合打卡体验。"
|
||||
voiceover = "今天分享一个好东西"
|
||||
# 口播超长时截断(字数上限统一从 constants 取)
|
||||
_max_v = get_duration_spec(dur)["max_chars"]
|
||||
if len(voiceover) > _max_v:
|
||||
@@ -1699,11 +1732,55 @@ def _finalize_fallback_script(job: ViralVideoJob) -> dict:
|
||||
return fb
|
||||
|
||||
|
||||
# ── PR3 P2(e): Seedance/Wan prompt 常量与辅助函数(从 _assemble_seedance_prompt 中抽出) ──
|
||||
_SEEDANCE_OVERVIEW_HEADER = "【视频总览】"
|
||||
_SEEDANCE_REFS_HEADER = "【参考素材】"
|
||||
_SEEDANCE_SCRIPT_HEADER = "【分镜脚本】"
|
||||
_SEEDANCE_HARD_HEADER = "【硬性约束】"
|
||||
_SEEDANCE_NEG_HEADER = "【负面提示词】"
|
||||
_SEEDANCE_DEFAULT_SCENE = "专业、清晰、明亮有质感"
|
||||
_SEEDANCE_DURATION_FOOT = "(单次生成,时长严格匹配)"
|
||||
_SEEDANCE_NEG_JOINER = ","
|
||||
_WAN_HEAD_SCENE_FALLBACK = "画面清晰有质感"
|
||||
_WAN_STYLE_LABEL = "风格说明:"
|
||||
_WAN_SHOT_PREFIX = "第{n}个镜头[{label}]"
|
||||
|
||||
|
||||
def _build_style_section(style_guide: dict | None, scene_lighting: str) -> str:
|
||||
"""从 style_guide / scene_lighting 生成整体风格描述文本。"""
|
||||
if not isinstance(style_guide, dict):
|
||||
return scene_lighting or _SEEDANCE_DEFAULT_SCENE
|
||||
parts: list[str] = []
|
||||
if scene_lighting:
|
||||
parts.append(scene_lighting)
|
||||
for k in ("color_grade", "energy", "cut_speed", "transition"):
|
||||
v = style_guide.get(k)
|
||||
if v and v != "placeholder":
|
||||
parts.append(f"{k}={v}")
|
||||
if style_guide.get("narrative"):
|
||||
parts.append("叙事感强")
|
||||
return ",".join(parts) if parts else _SEEDANCE_DEFAULT_SCENE
|
||||
|
||||
|
||||
def _build_character_section(person_portraits, pre_trusted_images, trusted_urls) -> str:
|
||||
"""人物参考段落。当前版本人物参考走 reference_images 通道,
|
||||
不在 prompt 正文重复描述;保留接口供后续扩展,当前返回空串。
|
||||
"""
|
||||
# person_portraits / pre_trusted_images / trusted_urls 目前由 call_video_generation
|
||||
# 通过 reference_images / pre_trusted_images 参数直接传 Seedance,不在文本 prompt
|
||||
# 中重复声明以避免冲突。此处保留函数入口以匹配设计文档,未来可扩展肖像一致性文本提示。
|
||||
return ""
|
||||
|
||||
|
||||
def _assemble_seedance_prompt(copy_result: dict, job: ViralVideoJob) -> str:
|
||||
"""把编导脚本拼成符合官方推荐格式的 prompt(按 provider 分发)。
|
||||
|
||||
- doubao (Seedance): [X-Y秒] 单行时间戳 + 镜头级 @图片N 绑定
|
||||
- dashscope (Wan 3.0): 第N个镜头[X-Y秒] 单行格式
|
||||
|
||||
PR3 P2(e): 字符串常量已抽取到模块级 _SEEDANCE_* / _WAN_*,
|
||||
风格段与人物段通过 _build_style_section / _build_character_section 组装,
|
||||
输出内容与旧版完全一致。
|
||||
"""
|
||||
from packages.domain.points_rules import get_viral_video_model_config
|
||||
|
||||
@@ -1719,6 +1796,7 @@ def _assemble_seedance_prompt(copy_result: dict, job: ViralVideoJob) -> str:
|
||||
hc = copy_result.get("hard_constraints") or _DEFAULT_HARD_CONSTRAINTS
|
||||
np_list = copy_result.get("negative_prompts") or _DEFAULT_NEGATIVE_PROMPTS
|
||||
images = list(job.images or [])
|
||||
style_guide = job.style_guide if isinstance(getattr(job, "style_guide", None), dict) else None
|
||||
|
||||
model = getattr(job, "video_model", "") or None
|
||||
provider = get_viral_video_model_config(model).get("provider", "doubao")
|
||||
@@ -1730,9 +1808,15 @@ def _assemble_seedance_prompt(copy_result: dict, job: ViralVideoJob) -> str:
|
||||
return idx
|
||||
return 0 if images else None
|
||||
|
||||
# 整体风格描述(复用 style_guide 如果有)
|
||||
style_text = _build_style_section(style_guide, scene_lighting)
|
||||
# 人物段(当前为空实现,仅保留扩展点,保持输出不变)
|
||||
# 未来可注入 person_portraits/pre_trusted 提示,目前人物参考走 reference_images 通道
|
||||
_char_text = _build_character_section([], getattr(job, "pre_trusted_images", None) or [], [])
|
||||
|
||||
if provider == "dashscope":
|
||||
# ========== Wan 3.0 格式 ==========
|
||||
head = f"{theme},{scene_lighting or '画面清晰有质感'},总时长{total_duration}秒。"
|
||||
head = f"{theme},{style_text or _WAN_HEAD_SCENE_FALLBACK},总时长{total_duration}秒。"
|
||||
body: list[str] = [head, ""]
|
||||
for i, s in enumerate(shots):
|
||||
tr = str(s.get("time_range", ""))
|
||||
@@ -1740,40 +1824,37 @@ def _assemble_seedance_prompt(copy_result: dict, job: ViralVideoJob) -> str:
|
||||
label = f"{pr[0]}-{pr[1]}秒" if pr else tr
|
||||
cam = str(s.get("shot_type_angle_movement", "") or "").strip()
|
||||
voice = str(s.get("voiceover", "") or "").strip()
|
||||
pieces = [f"第{i + 1}个镜头[{label}]"]
|
||||
pieces = [_WAN_SHOT_PREFIX.format(n=i + 1, label=label)]
|
||||
if cam:
|
||||
pieces.append(f"运镜:{cam}")
|
||||
pieces.append(f"画面:{_shot_visual(s)}")
|
||||
if voice:
|
||||
pieces.append(f'配音:"{voice}"')
|
||||
# Wan 通过 input.media 数组顺序隐式引用图1/图2,不写@图片
|
||||
body.append(" ".join(pieces))
|
||||
style_parts = [str(x) for x in hc if x] + [str(x) for x in np_list if x]
|
||||
if style_parts:
|
||||
body.append("")
|
||||
body.append("风格说明:" + ";".join(style_parts[:6]))
|
||||
body.append(_WAN_STYLE_LABEL + ";".join(style_parts[:6]))
|
||||
return "\n".join(body)
|
||||
|
||||
# ========== Seedance 格式 ==========
|
||||
lines: list[str] = []
|
||||
lines.append("【视频总览】")
|
||||
lines.append(_SEEDANCE_OVERVIEW_HEADER)
|
||||
lines.append(f"- 主题:{theme}")
|
||||
lines.append(f"- 总时长:{total_duration}秒(单次生成,时长严格匹配)")
|
||||
lines.append(f"- 总时长:{total_duration}秒{_SEEDANCE_DURATION_FOOT}")
|
||||
lines.append(f"- 画幅:{aspect_ratio}")
|
||||
lines.append(f"- 整体风格:{scene_lighting or '专业、清晰、明亮有质感'}")
|
||||
lines.append(f"- 整体风格:{style_text}")
|
||||
lines.append("")
|
||||
|
||||
# 【参考素材】按镜头绑定
|
||||
if images:
|
||||
lines.append("【参考素材】")
|
||||
lines.append(_SEEDANCE_REFS_HEADER)
|
||||
for i in range(len(shots)):
|
||||
ri = _ref(i)
|
||||
if ri is not None:
|
||||
lines.append(f"镜头{i + 1}参考@图片{ri + 1}")
|
||||
lines.append("")
|
||||
|
||||
# 单行时间戳分镜
|
||||
lines.append("【分镜脚本】")
|
||||
lines.append(_SEEDANCE_SCRIPT_HEADER)
|
||||
for i, s in enumerate(shots):
|
||||
tr = str(s.get("time_range", ""))
|
||||
pr = _parse_shot_seconds(tr)
|
||||
@@ -1795,12 +1876,12 @@ def _assemble_seedance_prompt(copy_result: dict, job: ViralVideoJob) -> str:
|
||||
lines.append(" ".join(pieces))
|
||||
|
||||
lines.append("")
|
||||
lines.append("【硬性约束】")
|
||||
lines.append(_SEEDANCE_HARD_HEADER)
|
||||
for c in hc:
|
||||
lines.append(f"- {c}")
|
||||
lines.append("")
|
||||
lines.append("【负面提示词】")
|
||||
lines.append(",".join([str(x) for x in np_list if x]))
|
||||
lines.append(_SEEDANCE_NEG_HEADER)
|
||||
lines.append(_SEEDANCE_NEG_JOINER.join([str(x) for x in np_list if x]))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -1935,14 +2016,22 @@ def _step_render(job: ViralVideoJob, copy_result: dict, tts_audio_url: str | Non
|
||||
user_msg,
|
||||
(detail or "")[:500],
|
||||
)
|
||||
raise RuntimeError(err_msg)
|
||||
raise ViralVideoUpstreamError(
|
||||
provider=err_provider,
|
||||
upstream_code=str(err_code),
|
||||
user_message=err_msg,
|
||||
detail=(detail or "")[:1000] or None,
|
||||
status_code=int(status_code) if isinstance(status_code, int) and status_code else None,
|
||||
)
|
||||
|
||||
if not result or not isinstance(result, dict):
|
||||
_check_and_reraise(result)
|
||||
video_path = result.get("video_path") or ""
|
||||
usage = result.get("usage")
|
||||
if not video_path or not Path(video_path).exists() or Path(video_path).stat().st_size == 0:
|
||||
raise RuntimeError("视频生成失败:返回空文件或路径不存在")
|
||||
raise ViralVideoResourceError(
|
||||
resource_type="video_file", user_message="视频生成失败:返回空文件或路径不存在", detail=f"path={video_path}"
|
||||
)
|
||||
# #2170: 统一走方舟 Seedance(含信任链),usage 里的 provider/model_key 用于积分结算;
|
||||
# 信任链产生的 Seedream 图成本已在利润率中覆盖,不单独结算。
|
||||
size = Path(video_path).stat().st_size
|
||||
@@ -1959,7 +2048,12 @@ def _step_upload(job: ViralVideoJob, video_path: str) -> str:
|
||||
logger.info("[爆款视频] 开始上传成片: local=%s key=%s size=%d", local, storage_key, local.stat().st_size)
|
||||
video_url = upload_to_oss(local, storage_key)
|
||||
if not video_url:
|
||||
raise RuntimeError(f"OSS 上传失败: storage_key={storage_key}")
|
||||
raise ViralVideoUpstreamError(
|
||||
provider="oss",
|
||||
upstream_code="upload_failed",
|
||||
user_message="视频上传失败,请稍后重试",
|
||||
detail=f"storage_key={storage_key}",
|
||||
)
|
||||
return video_url
|
||||
|
||||
|
||||
@@ -2423,7 +2517,11 @@ def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
|
||||
job.status,
|
||||
len((job.intent_result or {}) if isinstance(job.intent_result, dict) else {}),
|
||||
)
|
||||
raise ValueError("文案数据缺失,请先点击「生成文案」完成文案生成后再生成视频")
|
||||
raise ViralVideoError(
|
||||
code="copy_missing",
|
||||
user_message="文案数据缺失,请先点击「生成文案」完成文案生成后再生成视频",
|
||||
http_status=400,
|
||||
)
|
||||
logger.info(
|
||||
"[爆款视频][阶段3] 进入渲染流程 job_id=%s copy_result_shots=%d copy_result_len=%d source=%s",
|
||||
job_id,
|
||||
@@ -2455,12 +2553,16 @@ def _run_render_pipeline(job_id: str, session, repo, job) -> dict:
|
||||
job_id,
|
||||
[i.get("text", "")[:40] for i in _error_issues[:3]],
|
||||
)
|
||||
raise ValueError("文案合规审核未通过,请修改文案后重试或重新生成文案")
|
||||
raise ViralVideoComplianceError(
|
||||
user_message="文案合规审核未通过,请修改文案后重试或重新生成文案",
|
||||
issues=list(_error_issues),
|
||||
detail=f"{len(_error_issues)} error issues",
|
||||
)
|
||||
job.copy_result = copy_result
|
||||
job.generated_copy_text = copy_result.get("voiceover_script", "") or ""
|
||||
_save_job(repo, job, session)
|
||||
except ValueError:
|
||||
# #2218: 审核未通过/文案缺失的业务异常,不继续出片,向上抛出
|
||||
except (ValueError, ViralVideoError):
|
||||
# #2218 / PR3: 业务异常(审核未通过/文案缺失/积分不足等)不继续出片,向上抛出
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning("[爆款视频][阶段3] 合规审核异常,继续出片: %s", e)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""爆款视频参数指导(persona/viral_structure/marketing_purpose/target_customer/language)。
|
||||
|
||||
PR3 P2(h): 把原来 worker 里 130 行 if/elif 硬编码 mapping 迁到 DB 表
|
||||
`viral_video_param_guides`,提供 30 秒 TTL 缓存读取。DB miss 时返回 None,
|
||||
由调用方兜底到代码默认值(保证 worker 冷启动/DB 不可用时不阻断流程)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from packages.adapters.sqlalchemy_impl import session as _session_mod
|
||||
|
||||
CACHE_TTL_SECONDS = 30.0
|
||||
|
||||
VALID_GUIDE_TYPES = frozenset({"persona", "structure", "marketing_purpose", "target_customer", "language"})
|
||||
|
||||
_lock = threading.Lock()
|
||||
# key = f"{guide_type}:{guide_key}", value = (expire_monotonic_ts, text|None)
|
||||
_cache: dict[str, tuple[float, Optional[str]]] = {}
|
||||
|
||||
_lazy_session = None
|
||||
|
||||
|
||||
def _get_session():
|
||||
"""优先复用 worker 的 SessionLocal;否则懒建(API 场景)。"""
|
||||
global _lazy_session
|
||||
if _session_mod.SessionLocal is not None:
|
||||
return _session_mod.SessionLocal()
|
||||
if _lazy_session is not None:
|
||||
return _lazy_session()
|
||||
try:
|
||||
from packages.config import get_shared_settings
|
||||
|
||||
url = str(get_shared_settings().database_url)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
if not url:
|
||||
return None
|
||||
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://")
|
||||
if url.startswith("postgresql://") and "psycopg" not in url:
|
||||
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||
engine = sa.create_engine(url, pool_pre_ping=True, pool_size=2, max_overflow=2)
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
_lazy_session = sessionmaker(bind=engine)
|
||||
return _lazy_session()
|
||||
|
||||
|
||||
def _cache_key(guide_type: str, guide_key: str) -> str:
|
||||
return f"{guide_type}:{guide_key}"
|
||||
|
||||
|
||||
def _load_from_db(guide_type: str, guide_key: str) -> Optional[str]:
|
||||
session = None
|
||||
try:
|
||||
session = _get_session()
|
||||
if session is None:
|
||||
return None
|
||||
sql = sa.text("""
|
||||
SELECT guide_text
|
||||
FROM viral_video_param_guides
|
||||
WHERE guide_type = :gt AND guide_key = :gk
|
||||
""")
|
||||
row = session.execute(sql, {"gt": guide_type, "gk": guide_key}).first()
|
||||
if row is None:
|
||||
return None
|
||||
return row[0] if row[0] is not None else None
|
||||
except Exception: # noqa: BLE001 - 表不存在/DB 不可用静默返回 None
|
||||
return None
|
||||
finally:
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def get_param_guide(guide_type: str, guide_key: str, *, force_refresh: bool = False) -> Optional[str]:
|
||||
"""读参数指导文本,30 秒 TTL;DB 无数据返回 None,调用方自行兜底。"""
|
||||
if guide_type not in VALID_GUIDE_TYPES:
|
||||
return None
|
||||
gk = (guide_key or "").strip()
|
||||
if not gk:
|
||||
return None
|
||||
key = _cache_key(guide_type, gk)
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
if not force_refresh:
|
||||
cached = _cache.get(key)
|
||||
if cached and now < cached[0]:
|
||||
return cached[1]
|
||||
|
||||
text = _load_from_db(guide_type, gk)
|
||||
with _lock:
|
||||
_cache[key] = (now + CACHE_TTL_SECONDS, text)
|
||||
return text
|
||||
|
||||
|
||||
def invalidate() -> None:
|
||||
"""清空缓存(测试用)。"""
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CACHE_TTL_SECONDS",
|
||||
"VALID_GUIDE_TYPES",
|
||||
"get_param_guide",
|
||||
"invalidate",
|
||||
]
|
||||
@@ -1,10 +1,12 @@
|
||||
"""Prompt 模板加载器:从 viral_video_prompt_templates 读模板,30 秒 TTL 热加载。
|
||||
|
||||
DB 不可用或没有数据时自动回落到 prompts.DEFAULT_TEMPLATES,保证流程不阻断。
|
||||
DB 不可用或没有数据时自动回落到 prompts.DEFAULT_TEMPLATES,保证流程不阻断;
|
||||
首次回落会打 warning(PR3 P2(f)),避免静默掩盖配置问题,又不刷屏。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
@@ -15,10 +17,28 @@ import sqlalchemy as sa
|
||||
from packages.adapters.sqlalchemy_impl import session as _session_mod
|
||||
from packages.application.viral_video.prompts import DEFAULT_TEMPLATES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CACHE_TTL_SECONDS = 30.0
|
||||
|
||||
_VALID_TYPES = {"image_analysis", "storyboard", "review"}
|
||||
|
||||
# PR3 P2(f): 回落 warning 去重 — key -> 上次打 warning 的 monotonic 时间戳,
|
||||
# 同一 key 在 _WARN_COOLDOWN_SECONDS 内不再重复告警。
|
||||
_WARN_COOLDOWN_SECONDS = 300.0 # 5 分钟
|
||||
_warned: dict[str, float] = {}
|
||||
_warn_lock = threading.Lock()
|
||||
|
||||
|
||||
def _warn_once(key: str, template_type: str, e: BaseException | str | None = None) -> None:
|
||||
now = time.monotonic()
|
||||
with _warn_lock:
|
||||
last = _warned.get(key, 0.0)
|
||||
if now - last < _WARN_COOLDOWN_SECONDS:
|
||||
return
|
||||
_warned[key] = now
|
||||
logger.warning("viral_video prompt template %s fallback to default: %s", template_type, e)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PromptTemplate:
|
||||
@@ -64,12 +84,15 @@ def _get_session():
|
||||
from packages.config import get_shared_settings
|
||||
|
||||
url = str(get_shared_settings().database_url)
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception as e: # noqa: BLE001
|
||||
_warn_once("session_config", "db_config", e)
|
||||
return None
|
||||
if not url:
|
||||
_warn_once("session_url", "db_url", "database_url empty")
|
||||
return None
|
||||
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg://")
|
||||
url = url.replace("postgresql://", "postgresql+psycopg://") if url.startswith("postgresql://") else url
|
||||
if url.startswith("postgresql://") and "psycopg" not in url:
|
||||
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
|
||||
engine = sa.create_engine(url, pool_pre_ping=True, pool_size=2, max_overflow=2)
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
@@ -104,14 +127,15 @@ def _load_from_db(prompt_type: str) -> Optional[PromptTemplate]:
|
||||
example_output=row[5] or "",
|
||||
is_active=bool(row[6]),
|
||||
)
|
||||
except Exception: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
|
||||
except Exception as e: # noqa: BLE001 - 表不存在/DB 不可用时静默回落
|
||||
_warn_once(f"load_db:{prompt_type}", prompt_type, e)
|
||||
return None
|
||||
finally:
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
except Exception as e: # noqa: BLE001
|
||||
_warn_once(f"session_close:{prompt_type}", prompt_type, e)
|
||||
|
||||
|
||||
def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[PromptTemplate]:
|
||||
@@ -126,6 +150,8 @@ def get_template(prompt_type: str, *, force_refresh: bool = False) -> Optional[P
|
||||
return cached[1]
|
||||
|
||||
template = _load_from_db(prompt_type) or _fallback(prompt_type)
|
||||
if template is None:
|
||||
_warn_once(f"no_template:{prompt_type}", prompt_type, "no template in db or code defaults")
|
||||
if template is not None:
|
||||
with _lock:
|
||||
_cache[prompt_type] = (now, template)
|
||||
@@ -136,6 +162,8 @@ def invalidate() -> None:
|
||||
"""清空缓存(测试用)。"""
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
with _warn_lock:
|
||||
_warned.clear()
|
||||
|
||||
|
||||
class _SafeDict(dict):
|
||||
@@ -146,7 +174,8 @@ class _SafeDict(dict):
|
||||
def _safe_format(text: str, kwargs: dict) -> str:
|
||||
try:
|
||||
return text.format_map(_SafeDict(kwargs))
|
||||
except Exception: # noqa: BLE001
|
||||
except Exception as e: # noqa: BLE001
|
||||
_warn_once("safe_format", "format", e)
|
||||
return text
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""爆款视频领域层包(PR3 P2:从单文件转为包,保持原有对外符号不变)。"""
|
||||
|
||||
from packages.domain.viral_video.entities import (
|
||||
STAGE_LABELS,
|
||||
FusionLevel,
|
||||
PromptType,
|
||||
StyleStrength,
|
||||
ViralVideoJob,
|
||||
ViralVideoStage,
|
||||
ViralVideoStatus,
|
||||
)
|
||||
from packages.domain.viral_video.errors import (
|
||||
ViralVideoComplianceError,
|
||||
ViralVideoConfigError,
|
||||
ViralVideoCreditsError,
|
||||
ViralVideoError,
|
||||
ViralVideoLLMError,
|
||||
ViralVideoResourceError,
|
||||
ViralVideoTimeoutError,
|
||||
ViralVideoUpstreamError,
|
||||
)
|
||||
from packages.domain.viral_video.stages import (
|
||||
PROGRESS_COPY_DONE,
|
||||
PROGRESS_COPY_START,
|
||||
PROGRESS_COPY_STREAMING,
|
||||
PROGRESS_DONE,
|
||||
PROGRESS_IMAGE_DONE,
|
||||
PROGRESS_REVIEW,
|
||||
PROGRESS_START,
|
||||
PROGRESS_TRUST,
|
||||
PROGRESS_TTS,
|
||||
PROGRESS_UPLOAD_DONE,
|
||||
PROGRESS_VIDEO_DONE,
|
||||
PROGRESS_VIDEO_START,
|
||||
STAGE_COPY_GENERATION,
|
||||
STAGE_IMAGE_ANALYSIS,
|
||||
STAGE_INTENT_PARSING,
|
||||
STAGE_PHASE_MESSAGES,
|
||||
STAGE_REVIEW,
|
||||
STAGE_TRUST_CHAIN,
|
||||
STAGE_TTS,
|
||||
STAGE_UPLOADING,
|
||||
STAGE_VIDEO_ANALYSIS,
|
||||
STAGE_VIDEO_GENERATION,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# entities
|
||||
"FusionLevel",
|
||||
"PromptType",
|
||||
"STAGE_LABELS",
|
||||
"StyleStrength",
|
||||
"ViralVideoJob",
|
||||
"ViralVideoStage",
|
||||
"ViralVideoStatus",
|
||||
# errors
|
||||
"ViralVideoError",
|
||||
"ViralVideoConfigError",
|
||||
"ViralVideoUpstreamError",
|
||||
"ViralVideoComplianceError",
|
||||
"ViralVideoResourceError",
|
||||
"ViralVideoCreditsError",
|
||||
"ViralVideoTimeoutError",
|
||||
"ViralVideoLLMError",
|
||||
# stages
|
||||
"STAGE_IMAGE_ANALYSIS",
|
||||
"STAGE_VIDEO_ANALYSIS",
|
||||
"STAGE_COPY_GENERATION",
|
||||
"STAGE_INTENT_PARSING",
|
||||
"STAGE_REVIEW",
|
||||
"STAGE_TRUST_CHAIN",
|
||||
"STAGE_TTS",
|
||||
"STAGE_VIDEO_GENERATION",
|
||||
"STAGE_UPLOADING",
|
||||
"STAGE_PHASE_MESSAGES",
|
||||
"PROGRESS_START",
|
||||
"PROGRESS_IMAGE_DONE",
|
||||
"PROGRESS_COPY_START",
|
||||
"PROGRESS_COPY_STREAMING",
|
||||
"PROGRESS_COPY_DONE",
|
||||
"PROGRESS_REVIEW",
|
||||
"PROGRESS_TRUST",
|
||||
"PROGRESS_TTS",
|
||||
"PROGRESS_VIDEO_START",
|
||||
"PROGRESS_VIDEO_DONE",
|
||||
"PROGRESS_UPLOAD_DONE",
|
||||
"PROGRESS_DONE",
|
||||
]
|
||||
@@ -0,0 +1,167 @@
|
||||
"""爆款视频领域统一异常体系(PR3 P2)。
|
||||
|
||||
所有爆款视频相关业务异常均继承 ViralVideoError,保证:
|
||||
1. 上层可统一捕获,按 code/user_message 组装前端响应;
|
||||
2. 不暴露内部栈/traceback;
|
||||
3. http_status 供 API 层映射 HTTP 状态码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class ViralVideoError(Exception):
|
||||
"""爆款视频业务异常基类。"""
|
||||
|
||||
code: str = "viral_video_error"
|
||||
user_message: str = "视频生成失败,请稍后重试"
|
||||
detail: str | None = None
|
||||
http_status: int = 500
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_message: str | None = None,
|
||||
detail: str | None = None,
|
||||
code: str | None = None,
|
||||
http_status: int | None = None,
|
||||
) -> None:
|
||||
if user_message is not None:
|
||||
self.user_message = user_message
|
||||
self.detail = detail
|
||||
if code is not None:
|
||||
self.code = code
|
||||
if http_status is not None:
|
||||
self.http_status = http_status
|
||||
super().__init__(self.user_message)
|
||||
|
||||
def __str__(self) -> str: # pragma: no cover
|
||||
return f"[{self.code}] {self.user_message}" + (f" :: {self.detail}" if self.detail else "")
|
||||
|
||||
|
||||
class ViralVideoConfigError(ViralVideoError):
|
||||
"""配置/初始化错误:缺模型、缺 API Key、依赖未就绪等。"""
|
||||
|
||||
code = "config_error"
|
||||
http_status = 500
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_message: str = "服务配置异常,请稍后重试",
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
class ViralVideoUpstreamError(ViralVideoError):
|
||||
"""上游模型/云服务错误(火山 Seedance、阿里 Wan、OSS 等)。"""
|
||||
|
||||
code = "upstream_error"
|
||||
http_status = 502
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
upstream_code: str = "unknown",
|
||||
user_message: str = "视频生成服务暂时不可用,请稍后重试",
|
||||
detail: str | None = None,
|
||||
status_code: int | None = None,
|
||||
) -> None:
|
||||
self.provider = provider
|
||||
self.upstream_code = upstream_code
|
||||
self.status_code = status_code
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
class ViralVideoComplianceError(ViralVideoError):
|
||||
"""合规审核不通过(用户文案命中敏感/违规内容)。"""
|
||||
|
||||
code = "compliance_rejected"
|
||||
http_status = 400
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_message: str = "文案未通过合规审核,请修改后重试",
|
||||
issues: list | None = None,
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
self.issues: list = list(issues) if issues else []
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
class ViralVideoResourceError(ViralVideoError):
|
||||
"""资源不存在/失效:OSS URL 过期、图片找不到、返回空文件等。"""
|
||||
|
||||
code = "resource_error"
|
||||
http_status = 502
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource_type: str = "file",
|
||||
resource_id: str = "",
|
||||
user_message: str = "素材失效,请重新上传后重试",
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
self.resource_type = resource_type
|
||||
self.resource_id = resource_id
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
class ViralVideoCreditsError(ViralVideoError):
|
||||
"""积分不足/结算失败。"""
|
||||
|
||||
code = "credits_insufficient"
|
||||
http_status = 402
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_message: str = "积分不足,请充值后重试",
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
class ViralVideoTimeoutError(ViralVideoError):
|
||||
"""某阶段执行超时(LLM/渲染/上传)。"""
|
||||
|
||||
code = "timeout"
|
||||
http_status = 504
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stage: str,
|
||||
timeout_s: float = 0.0,
|
||||
user_message: str = "处理超时,请稍后重试",
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
self.stage = stage
|
||||
self.timeout_s = timeout_s
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
class ViralVideoLLMError(ViralVideoError):
|
||||
"""LLM 调用失败/重试耗尽/解析失败。"""
|
||||
|
||||
code = "llm_error"
|
||||
http_status = 502
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stage: str = "script_generation",
|
||||
model: str = "",
|
||||
user_message: str = "文案生成失败,请稍后重试",
|
||||
detail: str | None = None,
|
||||
) -> None:
|
||||
self.stage = stage
|
||||
self.model = model
|
||||
super().__init__(user_message=user_message, detail=detail)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ViralVideoError",
|
||||
"ViralVideoConfigError",
|
||||
"ViralVideoUpstreamError",
|
||||
"ViralVideoComplianceError",
|
||||
"ViralVideoResourceError",
|
||||
"ViralVideoCreditsError",
|
||||
"ViralVideoTimeoutError",
|
||||
"ViralVideoLLMError",
|
||||
]
|
||||
@@ -0,0 +1,71 @@
|
||||
"""爆款视频阶段与进度点常量(PR3 P2)。
|
||||
|
||||
worker/API 中硬编码的阶段字符串与进度数值集中到此文件统一维护;
|
||||
注意:Celery task name / HTTP path / WS event name 属对外契约,不在此列。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# ── 阶段标识(对齐 ViralVideoStage 但更贴合 v1.6 实际链路) ──────────────
|
||||
STAGE_IMAGE_ANALYSIS: str = "image_analysis"
|
||||
STAGE_VIDEO_ANALYSIS: str = "video_analysis"
|
||||
STAGE_COPY_GENERATION: str = "script_generation" # v1.6 文案=编导分镜
|
||||
STAGE_INTENT_PARSING: str = "intent_parsing"
|
||||
STAGE_REVIEW: str = "review"
|
||||
STAGE_TRUST_CHAIN: str = "trust_chain" # #2172 信任链预热
|
||||
STAGE_TTS: str = "tts"
|
||||
STAGE_VIDEO_GENERATION: str = "rendering"
|
||||
STAGE_UPLOADING: str = "uploading"
|
||||
|
||||
# ── 阶段中文提示 ────────────────────────────────────────────────────────
|
||||
STAGE_PHASE_MESSAGES: dict[str, str] = {
|
||||
STAGE_IMAGE_ANALYSIS: "正在分析图片内容...",
|
||||
STAGE_VIDEO_ANALYSIS: "正在分析参考视频风格...",
|
||||
STAGE_COPY_GENERATION: "正在生成文案与分镜...",
|
||||
STAGE_INTENT_PARSING: "正在解析需求...",
|
||||
STAGE_REVIEW: "正在进行出片前合规审核...",
|
||||
STAGE_TRUST_CHAIN: "正在AI化人像参考...",
|
||||
STAGE_TTS: "正在合成AI配音...",
|
||||
STAGE_VIDEO_GENERATION: "正在生成视频(约1-3分钟)...",
|
||||
STAGE_UPLOADING: "正在上传成片...",
|
||||
}
|
||||
|
||||
# ── 关键进度点(0-100)────────────────────────────────────────────────
|
||||
PROGRESS_START: float = 0.0
|
||||
PROGRESS_IMAGE_DONE: float = 10.0 # 图片分析完成
|
||||
PROGRESS_COPY_START: float = 15.0 # 进入文案生成
|
||||
PROGRESS_COPY_STREAMING: float = 30.0 # 文案流式输出中
|
||||
PROGRESS_COPY_DONE: float = 45.0 # 文案生成完成
|
||||
PROGRESS_REVIEW: float = 50.0 # 合规审核
|
||||
PROGRESS_TRUST: float = 60.0 # 信任链 AI 化
|
||||
PROGRESS_TTS: float = 65.0 # TTS 合成
|
||||
PROGRESS_VIDEO_START: float = 70.0 # 开始视频生成
|
||||
PROGRESS_VIDEO_DONE: float = 95.0 # 视频生成完成
|
||||
PROGRESS_UPLOAD_DONE: float = 98.0 # 上传完成
|
||||
PROGRESS_DONE: float = 100.0 # 全流程完成
|
||||
|
||||
|
||||
__all__ = [
|
||||
"STAGE_IMAGE_ANALYSIS",
|
||||
"STAGE_VIDEO_ANALYSIS",
|
||||
"STAGE_COPY_GENERATION",
|
||||
"STAGE_INTENT_PARSING",
|
||||
"STAGE_REVIEW",
|
||||
"STAGE_TRUST_CHAIN",
|
||||
"STAGE_TTS",
|
||||
"STAGE_VIDEO_GENERATION",
|
||||
"STAGE_UPLOADING",
|
||||
"STAGE_PHASE_MESSAGES",
|
||||
"PROGRESS_START",
|
||||
"PROGRESS_IMAGE_DONE",
|
||||
"PROGRESS_COPY_START",
|
||||
"PROGRESS_COPY_STREAMING",
|
||||
"PROGRESS_COPY_DONE",
|
||||
"PROGRESS_REVIEW",
|
||||
"PROGRESS_TRUST",
|
||||
"PROGRESS_TTS",
|
||||
"PROGRESS_VIDEO_START",
|
||||
"PROGRESS_VIDEO_DONE",
|
||||
"PROGRESS_UPLOAD_DONE",
|
||||
"PROGRESS_DONE",
|
||||
]
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Seed viral_video_param_guides 表(PR3 P2-h)。
|
||||
|
||||
将 worker 中硬编码的人设/结构/语言默认值灌入 DB,保证部署后查询有默认返回;
|
||||
运营可后续在后台修改,30s TTL 缓存自动生效。
|
||||
|
||||
用法:
|
||||
cd /root/work/xiaoxia-saas
|
||||
PYTHONPATH=. python scripts/seed_viral_video_param_guides.py
|
||||
|
||||
幂等:用 ON CONFLICT (guide_type, guide_key) DO UPDATE 保证重复执行安全。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from sqlalchemy import create_engine, text # noqa: E402
|
||||
|
||||
SEED_DATA: list[tuple[str, str, str]] = [
|
||||
# ── persona:人设风格 ──────────────────────────────────────────────
|
||||
("persona", "宝妈", "温柔贴心,多从带娃和家庭实用角度分享,语气亲切有共鸣"),
|
||||
("persona", "店主", "热情实在,像当面招呼客人,突出靠谱和实在优惠"),
|
||||
("persona", "专业顾问", "专业可信,讲清原理和效果,用事实打消顾虑"),
|
||||
("persona", "年轻达人", "活泼有网感,节奏轻快,金句和梗自然不尬"),
|
||||
("persona", "老板型IP", "以老板第一人称出镜,真诚接地气,像招呼街坊邻居一样分享,突出创业初心和靠谱"),
|
||||
("persona", "知识博主", "条理清晰、数据说话,干货密度高,语气专业但不枯燥"),
|
||||
("persona", "生活美学", "画面感强,注重氛围和质感描述,语速偏慢,文字有诗意"),
|
||||
("persona", "健身教练", "energetic、鼓励式口吻,强调动作要领和效果变化"),
|
||||
("persona", "美妆达人", "细腻讲质地和妆效,像闺蜜安利,语气亲切有感染力"),
|
||||
("persona", "美食博主", "色香味描述丰富,口语化带馋感,节奏轻快"),
|
||||
("persona", "穿搭博主", "讲搭配逻辑和场景适配,时尚但不高冷,像朋友建议"),
|
||||
("persona", "育儿师", "科学育儿角度,温柔坚定,给具体可操作的建议"),
|
||||
# ── structure:爆款结构 ────────────────────────────────────────────
|
||||
(
|
||||
"structure",
|
||||
"反差破局+亮明观点+还原现状",
|
||||
"开头3秒用反差/痛点钩子抓注意力,中段亮出核心卖点或观点,结尾还原真实到店/使用场景引导行动",
|
||||
),
|
||||
(
|
||||
"structure",
|
||||
"痛点切入+方案展示+效果对比",
|
||||
"开头直击用户痛点场景,中间展示产品/服务解决方案,结尾用前后对比强化效果",
|
||||
),
|
||||
(
|
||||
"structure",
|
||||
"故事引入+产品种草+行动引导",
|
||||
"用一个真实小故事/案例引入,自然过渡到产品种草,结尾明确引导用户下一步行动",
|
||||
),
|
||||
(
|
||||
"structure",
|
||||
"场景展示+价值输出+信任背书",
|
||||
"开头展示使用场景让用户代入,中间输出核心价值主张,结尾用客户评价/数据等信任背书收尾",
|
||||
),
|
||||
("structure", "悬念开场+层层递进+高潮转化", "开头制造悬念引发好奇,内容层层推进保持张力,高潮处给出转化钩子"),
|
||||
# ── language:语言提示 ─────────────────────────────────────────────
|
||||
("language", "zh-cn", "使用标准普通话,口语化表达"),
|
||||
("language", "zh-tw", "使用台湾腔中文,语气温柔亲切"),
|
||||
("language", "zh-hk", "使用粤语风格中文表达"),
|
||||
("language", "en-us", "使用美式英语,自然口语化"),
|
||||
("language", "en-gb", "使用英式英语"),
|
||||
("language", "ja-jp", "使用日语,自然口语化"),
|
||||
("language", "ko-kr", "使用韩语,亲切自然"),
|
||||
# ── marketing_purpose:营销目的(前端枚举 + 常见关键词,20项)─────────
|
||||
(
|
||||
"marketing_purpose",
|
||||
"获客引流",
|
||||
"【口播获客】第一人称对镜头发声,突出到店/加粉福利和行动引导,节奏紧凑有钩子,结尾明确CTA",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"账号涨粉",
|
||||
"【涨粉内容】开头有强反差/悬念钩子抓注意力,内容有信息密度和共鸣点,结尾引导关注不生硬",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"活动通知",
|
||||
"【活动通知】开头直接说活动主题和时间,中段讲福利/优惠力度,结尾强调截止时间和参与方式,信息清晰不绕弯",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"口播带货",
|
||||
"【口播带货】第一人称对镜头说话,像真人出镜推荐好物,有真实使用体验感,突出卖点和使用效果,结尾有购买引导",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"促销转化",
|
||||
"【促销转化】强调限时/限量/专属优惠,制造紧迫感,价格/优惠信息清晰突出,有明确购买路径",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"场景种草",
|
||||
"【场景种草】在真实使用场景中展示产品/服务,自然带出卖点,让用户产生代入感和'我也想试试'的欲望",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"痛点解决",
|
||||
"【痛点解决】开头直击目标用户的具体痛点场景,中段给出解决方案和使用演示,结尾强调效果对比",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"功能演示",
|
||||
"【功能演示】以产品功能展示为主,操作步骤清晰可见,讲清楚'怎么用'和'用了有什么效果',少废话多演示",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"门店发现",
|
||||
"【门店探店】店主或探店博主出镜,真实展示门店环境、招牌产品/服务、价格和体验感,结尾有到店引导",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"到店实录",
|
||||
"【到店实录】真实记录到店体验全过程,镜头跟着主走进店、看环境、体验服务、尝产品,节奏自然真实",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"招牌体验",
|
||||
"【招牌体验】聚焦门店/品牌的招牌产品/服务,细节展示特色卖点,突出'为什么这是招牌',有记忆点",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"同城团购",
|
||||
"【同城团购】突出本地/同城/到店属性,讲清楚团购优惠内容、使用规则、门店位置,结尾引导下单",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"品牌主张",
|
||||
"【品牌主张】传递品牌理念和价值观,有态度有情感共鸣,画面质感高级,少硬广多故事感",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"品牌故事",
|
||||
"【品牌故事】用叙事方式讲品牌的创立初心/工艺传承/用户故事,有情感起伏,建立品牌信任",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"生活方式",
|
||||
"【生活方式】营造向往的生活场景和氛围,产品/服务自然融入生活片段,调性好看有质感,引发'想过这样的生活'的共鸣",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"创意概念",
|
||||
"【创意概念】画面有创意和视觉冲击力,概念新颖不套路,适合做品牌传播内容,让人愿意转发分享",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"对话短剧",
|
||||
"【短剧模式】必须双/多角色对话格式,台词用'角色:xxx'格式,镜头在角色间切换,有剧情冲突或反转,禁止第三人称旁白解说",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"反转短剧",
|
||||
"【反转短剧】前半段建立常规预期,后半段出人意料反转,对话推动剧情,节奏紧凑有爽感,结尾反转干脆利落",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"悬念短剧",
|
||||
"【悬念短剧】开头制造悬念或谜题,剧情层层推进吊足胃口,结尾揭晓答案,角色对话推进,不要旁白解说",
|
||||
),
|
||||
(
|
||||
"marketing_purpose",
|
||||
"情绪短片",
|
||||
"【情绪短片】用画面、音乐和简短台词传递情绪(治愈/感动/激励/共鸣),节奏偏慢有氛围感,文字克制留白",
|
||||
),
|
||||
# ── target_customer:目标客群(前端枚举 8项)─────────────────────
|
||||
(
|
||||
"target_customer",
|
||||
"小镇青年",
|
||||
"【目标小镇青年】台词接地气有网感,贴近下沉市场生活场景,讲实在优惠和真实体验,节奏轻快不端着",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"小镇中老年",
|
||||
"【目标小镇中老年】台词慢节奏、吐字清晰、信息直白,强调实惠耐用和健康养生,少用网络梗",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"Z世代",
|
||||
"【目标Z世代】台词活泼有梗、节奏快,可用流行语和弹幕感表达,突出个性、有趣、社交话题性",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"精致妈妈",
|
||||
"【目标精致妈妈】台词温柔贴心,从育儿/家庭/品质生活角度切入,强调安全、省心、性价比,有真实带娃/家居场景共鸣",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"新锐白领",
|
||||
"【目标新锐白领】台词简洁干练,突出效率、品质、格调,讲职场/都市生活场景,少废话直接说价值",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"资深中产",
|
||||
"【目标资深中产】台词专业有深度,强调品质、品位、身份感,调性高级克制,不浮夸不喊麦",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"都市蓝领",
|
||||
"【目标都市蓝领】台词实在接地气,强调性价比、耐用、实惠,讲打工/租房/日常刚需场景,贴近生活",
|
||||
),
|
||||
(
|
||||
"target_customer",
|
||||
"都市银发",
|
||||
"【目标都市银发】台词清晰慢节奏,强调健康、实惠、方便操作,避免复杂术语和网络用语,信息直白",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _get_engine():
|
||||
"""获取 DB engine。优先 DATABASE_URL 环境变量;否则走应用配置(与 seed_viral_video_prompts.py 一致)。"""
|
||||
import os
|
||||
|
||||
database_url = os.environ.get("DATABASE_URL")
|
||||
if database_url:
|
||||
url = database_url
|
||||
else:
|
||||
from packages.config import get_shared_settings
|
||||
|
||||
url = str(get_shared_settings().database_url)
|
||||
# asyncpg → psycopg2(脚本里不能用 async driver)
|
||||
url = url.replace("postgresql+asyncpg://", "postgresql+psycopg2://")
|
||||
if url.startswith("postgresql://") and "psycopg" not in url:
|
||||
url = url.replace("postgresql://", "postgresql+psycopg2://", 1)
|
||||
return create_engine(url, pool_pre_ping=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
engine = _get_engine()
|
||||
insert_sql = text("""
|
||||
INSERT INTO viral_video_param_guides (guide_type, guide_key, guide_text)
|
||||
VALUES (:gt, :gk, :gtxt)
|
||||
ON CONFLICT (guide_type, guide_key)
|
||||
DO UPDATE SET guide_text = EXCLUDED.guide_text, updated_at = NOW()
|
||||
""")
|
||||
count = 0
|
||||
with engine.begin() as conn:
|
||||
for gt, gk, gtxt in SEED_DATA:
|
||||
conn.execute(insert_sql, {"gt": gt, "gk": gk, "gtxt": gtxt})
|
||||
count += 1
|
||||
print(f"[seed_viral_video_param_guides] upserted {count} rows")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,213 @@
|
||||
"""PR3 P2 爆款视频后端:errors/stages/param_guides 基础单测。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_errors_hierarchy():
|
||||
from packages.domain.viral_video import (
|
||||
ViralVideoComplianceError,
|
||||
ViralVideoConfigError,
|
||||
ViralVideoCreditsError,
|
||||
ViralVideoError,
|
||||
ViralVideoLLMError,
|
||||
ViralVideoResourceError,
|
||||
ViralVideoTimeoutError,
|
||||
ViralVideoUpstreamError,
|
||||
)
|
||||
|
||||
# 基类
|
||||
e = ViralVideoError()
|
||||
assert e.code == "viral_video_error"
|
||||
assert e.http_status == 500
|
||||
assert str(e)
|
||||
|
||||
# 上游错误
|
||||
ue = ViralVideoUpstreamError("doubao", upstream_code="50411", user_message="模型不可用", status_code=400)
|
||||
assert isinstance(ue, ViralVideoError)
|
||||
assert ue.provider == "doubao"
|
||||
assert ue.upstream_code == "50411"
|
||||
assert ue.http_status == 502
|
||||
assert ue.status_code == 400
|
||||
|
||||
# 合规错误带 issues
|
||||
ce = ViralVideoComplianceError(user_message="审核未通过", issues=[{"text": "违规", "severity": "error"}])
|
||||
assert ce.code == "compliance_rejected"
|
||||
assert ce.http_status == 400
|
||||
assert len(ce.issues) == 1
|
||||
|
||||
# 资源/积分/超时/LLM/配置
|
||||
assert ViralVideoResourceError("video_file", "x").http_status == 502
|
||||
assert ViralVideoCreditsError().http_status == 402
|
||||
te = ViralVideoTimeoutError("rendering", timeout_s=180)
|
||||
assert te.stage == "rendering"
|
||||
assert te.http_status == 504
|
||||
le = ViralVideoLLMError(stage="script", model="seed-2.1-lite", detail="max retries")
|
||||
assert le.http_status == 502
|
||||
assert le.model == "seed-2.1-lite"
|
||||
assert ViralVideoConfigError().http_status == 500
|
||||
|
||||
|
||||
def test_stages_constants():
|
||||
from packages.domain.viral_video import (
|
||||
PROGRESS_COPY_DONE,
|
||||
PROGRESS_COPY_START,
|
||||
PROGRESS_COPY_STREAMING,
|
||||
PROGRESS_DONE,
|
||||
PROGRESS_IMAGE_DONE,
|
||||
PROGRESS_REVIEW,
|
||||
PROGRESS_TRUST,
|
||||
PROGRESS_TTS,
|
||||
PROGRESS_UPLOAD_DONE,
|
||||
PROGRESS_VIDEO_DONE,
|
||||
PROGRESS_VIDEO_START,
|
||||
STAGE_COPY_GENERATION,
|
||||
STAGE_IMAGE_ANALYSIS,
|
||||
STAGE_PHASE_MESSAGES,
|
||||
STAGE_REVIEW,
|
||||
STAGE_TRUST_CHAIN,
|
||||
STAGE_TTS,
|
||||
STAGE_UPLOADING,
|
||||
STAGE_VIDEO_GENERATION,
|
||||
)
|
||||
|
||||
assert STAGE_IMAGE_ANALYSIS == "image_analysis"
|
||||
assert STAGE_COPY_GENERATION == "script_generation"
|
||||
assert STAGE_REVIEW == "review"
|
||||
assert STAGE_TRUST_CHAIN == "trust_chain"
|
||||
assert STAGE_VIDEO_GENERATION == "rendering"
|
||||
assert STAGE_UPLOADING == "uploading"
|
||||
# 进度单调递增
|
||||
assert PROGRESS_IMAGE_DONE < PROGRESS_COPY_START < PROGRESS_COPY_STREAMING < PROGRESS_COPY_DONE
|
||||
assert PROGRESS_COPY_DONE < PROGRESS_REVIEW < PROGRESS_TRUST < PROGRESS_TTS < PROGRESS_VIDEO_START
|
||||
assert PROGRESS_VIDEO_START < PROGRESS_VIDEO_DONE < PROGRESS_UPLOAD_DONE <= PROGRESS_DONE
|
||||
# 阶段中文提示
|
||||
assert "分析" in STAGE_PHASE_MESSAGES[STAGE_IMAGE_ANALYSIS]
|
||||
assert "审核" in STAGE_PHASE_MESSAGES[STAGE_REVIEW]
|
||||
|
||||
|
||||
def test_param_guides_invalid_type_returns_none():
|
||||
from packages.application.viral_video.param_guides import get_param_guide
|
||||
|
||||
assert get_param_guide("bogus", "x") is None
|
||||
assert get_param_guide("persona", "") is None
|
||||
assert get_param_guide("persona", " ") is None
|
||||
|
||||
|
||||
def test_fallback_script_honest_short(monkeypatch):
|
||||
"""兜底文案必须短、诚实、基于真实 VLM 输入。"""
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# mock celery/db 依赖
|
||||
for mod in ["worker_app.celery_app", "worker_app.db"]:
|
||||
monkeypatch.setitem(sys.modules, mod, MagicMock())
|
||||
import celery
|
||||
|
||||
monkeypatch.setattr(celery, "shared_task", lambda **kw: (lambda f: f))
|
||||
|
||||
from worker_app.tasks import viral_video as wv
|
||||
|
||||
from packages.domain.viral_video import ViralVideoJob
|
||||
|
||||
# 有 name+brand
|
||||
j = ViralVideoJob(user_id="u", duration=15, video_ratio="9:16")
|
||||
j.image_analysis = {"images": [{"name": "口红", "brand": "XX牌", "type": "product"}]}
|
||||
fb = wv._fallback_script(j)
|
||||
assert "XX牌" in fb["voiceover_script"]
|
||||
assert "口红" in fb["voiceover_script"]
|
||||
assert len(fb["voiceover_script"]) < 20 # 非常短,不再是营销套话
|
||||
assert fb["shots"] and len(fb["shots"]) == 1 # 只有一个镜头
|
||||
assert fb["shots"][0]["time_range"] == "0-15秒"
|
||||
|
||||
# 只有 name
|
||||
j2 = ViralVideoJob(user_id="u", duration=10)
|
||||
j2.image_analysis = {"images": [{"name": "面馆", "type": "store"}]}
|
||||
fb2 = wv._fallback_script(j2)
|
||||
assert "来看看面馆" in fb2["voiceover_script"]
|
||||
assert "都很赞" not in fb2["voiceover_script"] # 不再有营销套话
|
||||
|
||||
# 都没有
|
||||
j3 = ViralVideoJob(user_id="u", duration=15)
|
||||
j3.image_analysis = {"images": []}
|
||||
fb3 = wv._fallback_script(j3)
|
||||
assert "好东西" in fb3["voiceover_script"]
|
||||
|
||||
# user_copy_text 优先
|
||||
j4 = ViralVideoJob(user_id="u", duration=15, user_copy_text="自定义文案")
|
||||
j4.image_analysis = {"images": [{"name": "X", "brand": "Y"}]}
|
||||
fb4 = wv._fallback_script(j4)
|
||||
assert fb4["voiceover_script"] == "自定义文案"
|
||||
|
||||
|
||||
def test_video_analysis_returns_none_on_import_error(monkeypatch):
|
||||
"""ImportError 时返回 None,下游 isinstance(dict) 会跳过。"""
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
for mod in ["worker_app.celery_app", "worker_app.db"]:
|
||||
monkeypatch.setitem(sys.modules, mod, MagicMock())
|
||||
import celery
|
||||
|
||||
monkeypatch.setattr(celery, "shared_task", lambda **kw: (lambda f: f))
|
||||
|
||||
# 让 import viral_video.video_analyzer 抛 ImportError
|
||||
import builtins
|
||||
|
||||
from worker_app.tasks import viral_video as wv
|
||||
|
||||
from packages.domain.viral_video import ViralVideoJob
|
||||
|
||||
real_import = builtins.__import__
|
||||
|
||||
def fake_import(name, *a, **kw):
|
||||
if "video_analyzer" in name:
|
||||
raise ImportError("no module")
|
||||
return real_import(name, *a, **kw)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", fake_import)
|
||||
j = ViralVideoJob(user_id="u", reference_video_url="https://x/y.mp4")
|
||||
res = wv._step_video_analysis(j)
|
||||
assert res is None
|
||||
|
||||
|
||||
def test_seedance_prompt_uses_helpers(monkeypatch):
|
||||
"""_assemble_seedance_prompt 拆分后输出包含必要段落,且长度合理。"""
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
for mod in ["worker_app.celery_app", "worker_app.db"]:
|
||||
monkeypatch.setitem(sys.modules, mod, MagicMock())
|
||||
import celery
|
||||
|
||||
monkeypatch.setattr(celery, "shared_task", lambda **kw: (lambda f: f))
|
||||
|
||||
from worker_app.tasks import viral_video as wv
|
||||
|
||||
from packages.domain.viral_video import ViralVideoJob
|
||||
|
||||
j = ViralVideoJob(user_id="u", duration=15, video_ratio="9:16", images=["https://x/1.jpg"])
|
||||
copy_result = {
|
||||
"overview": {"theme": "口红试色", "total_duration": 15, "aspect_ratio": "9:16"},
|
||||
"scene_and_lighting": "暖光,室内美妆场景",
|
||||
"shots": [
|
||||
{
|
||||
"time_range": "0-15秒",
|
||||
"shot_type_angle_movement": "中景",
|
||||
"voiceover": "这支口红颜色超正",
|
||||
"reference_image_index": 0,
|
||||
"transition": "结束",
|
||||
}
|
||||
],
|
||||
"hard_constraints": ["无水印"],
|
||||
"negative_prompts": ["字幕"],
|
||||
}
|
||||
with patch("packages.domain.points_rules.get_viral_video_model_config", return_value={"provider": "doubao"}):
|
||||
with patch.object(wv, "_shot_visual", return_value="女生涂口红特写"):
|
||||
with patch.object(wv, "_parse_shot_seconds", return_value=(0, 15)):
|
||||
p = wv._assemble_seedance_prompt(copy_result, j)
|
||||
assert "【视频总览】" in p
|
||||
assert "【分镜脚本】" in p
|
||||
assert "【硬性约束】" in p
|
||||
assert "【负面提示词】" in p
|
||||
assert "@图片1" in p
|
||||
assert "口红试色" in p
|
||||
@@ -966,7 +966,7 @@ class TestIssue2249UserParamsFix:
|
||||
|
||||
def test_bug2_persona_style_guide_covers_boss_ip(self):
|
||||
"""Bug2: _PERSONA_STYLE_GUIDE 必须覆盖'老板型IP'"""
|
||||
from apps.worker.worker_app.tasks.viral_video import _PERSONA_STYLE_GUIDE
|
||||
from apps.worker.worker_app.tasks.viral_video import _DEFAULT_PERSONA_HINTS as _PERSONA_STYLE_GUIDE
|
||||
|
||||
assert "老板型IP" in _PERSONA_STYLE_GUIDE
|
||||
assert "老板" in _PERSONA_STYLE_GUIDE["老板型IP"] or "第一人称" in _PERSONA_STYLE_GUIDE["老板型IP"]
|
||||
|
||||
Reference in New Issue
Block a user