视频提词优化管理后台配置

This commit is contained in:
2026-06-22 14:39:00 +08:00
parent f62dd8bf67
commit 73567b4143
16 changed files with 1983 additions and 92 deletions
+2
View File
@@ -27,6 +27,7 @@ import AdminHotOpeningReplicationDetail from './pages/AdminHotOpeningReplication
import AdminShotReplications from './pages/AdminShotReplications';
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
import { useAdminStore } from './store';
const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
@@ -84,6 +85,7 @@ const App = () => {
<Route path="payment" element={<AdminPaymentConfig />} />
<Route path="payment-stats" element={<AdminPaymentStats />} />
<Route path="settings" element={<AdminSettings />} />
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
<Route path="notifications" element={<AdminNotificationManager />} />
<Route path="oauthapp-list" element={<AdminOauthAppList />} />
<Route path="operation-logs" element={<AdminOperationLogs />} />
+29
View File
@@ -10,6 +10,8 @@ import type {
AdminHotOpeningTaskQueryParams, HotOpeningTaskListOut, ReplicationProjectDetailOut,
AdminShotTaskSetQueryParams, ShotTaskSetListOut, ShotTaskSetDetailOut,
AdminShotSegmentQueryParams, ShotSegmentListOut, ShotSegmentDetailOut,
VideoPromptSchemaConfigOut, VideoPromptSchemaConfigSavePayload,
VideoPromptSchemaPreviewPayload, VideoPromptSchemaPreviewOut, VideoPromptSchemaExportOut,
} from '../types';
// ── Auth ──────────────────────────────────────────────────
@@ -511,3 +513,30 @@ export async function getAdminShotSegmentDetail(segmentId: string): Promise<Shot
export async function getAdminShotProjectDetail(projectId: string): Promise<ReplicationProjectDetailOut> {
return api.get<ReplicationProjectDetailOut>(`/shot-replications/projects/${projectId}`);
}
// ── Video Prompt Schema Config (Admin) ─────────────────────
export async function getVideoPromptSchemaConfig(): Promise<VideoPromptSchemaConfigOut> {
return api.get<VideoPromptSchemaConfigOut>('/admin/video-prompt-schema-config');
}
export async function saveVideoPromptSchemaConfig(payload: VideoPromptSchemaConfigSavePayload): Promise<VideoPromptSchemaConfigOut> {
return api.put<VideoPromptSchemaConfigOut>('/admin/video-prompt-schema-config', payload);
}
export async function resetVideoPromptSchemaConfig(): Promise<VideoPromptSchemaConfigOut> {
return api.post<VideoPromptSchemaConfigOut>('/admin/video-prompt-schema-config/reset-default');
}
export async function exportVideoPromptSchemaConfig(): Promise<VideoPromptSchemaExportOut> {
return api.get<VideoPromptSchemaExportOut>('/admin/video-prompt-schema-config/export');
}
export async function importVideoPromptSchemaConfig(payload: VideoPromptSchemaConfigSavePayload): Promise<VideoPromptSchemaConfigOut> {
return api.post<VideoPromptSchemaConfigOut>('/admin/video-prompt-schema-config/import', payload);
}
export async function previewVideoPromptSchemaConfig(payload: VideoPromptSchemaPreviewPayload): Promise<VideoPromptSchemaPreviewOut> {
return api.post<VideoPromptSchemaPreviewOut>('/admin/video-prompt-schema-config/preview', payload);
}
@@ -0,0 +1,707 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert,
Button,
Card,
Col,
Collapse,
Divider,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Spin,
Switch,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import {
DeleteOutlined,
DownloadOutlined,
EyeOutlined,
PlusOutlined,
ReloadOutlined,
SaveOutlined,
UploadOutlined,
} from '@ant-design/icons';
import {
exportVideoPromptSchemaConfig,
getVideoPromptSchemaConfig,
importVideoPromptSchemaConfig,
previewVideoPromptSchemaConfig,
resetVideoPromptSchemaConfig,
saveVideoPromptSchemaConfig,
} from '../api';
import type {
VideoPromptFlowItemField,
VideoPromptSchemaConfigData,
VideoPromptSchemaNode,
VideoPromptSchemaNodeType,
VideoPromptTimePlanRule,
} from '../types';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
const NODE_TYPES: { label: string; value: VideoPromptSchemaNodeType }[] = [
{ label: '对象', value: 'object' },
{ label: '数组', value: 'array' },
{ label: '字符串', value: 'string' },
{ label: '数字', value: 'number' },
{ label: '布尔', value: 'boolean' },
{ label: '流程数组', value: 'flow' },
];
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value ?? null));
}
function pick<T = any>(obj: any, camelKey: string, snakeKey: string, fallback?: T): T {
if (!obj || typeof obj !== 'object') return fallback as T;
if (obj[camelKey] !== undefined) return obj[camelKey] as T;
if (obj[snakeKey] !== undefined) return obj[snakeKey] as T;
return fallback as T;
}
function normalizeFlowField(field: any): VideoPromptFlowItemField {
return {
key: String(field?.key || field?.label || '新流程字段'),
label: String(field?.label || field?.key || '新流程字段'),
enabled: field?.enabled ?? true,
editable: field?.editable ?? true,
maxLength: Number(pick(field, 'maxLength', 'max_length', 800) || 800),
value: field?.value ?? '',
};
}
function normalizeNode(node: any): VideoPromptSchemaNode {
const type = String(node?.type || 'string') as VideoPromptSchemaNodeType;
const normalized: VideoPromptSchemaNode = {
key: String(node?.key || node?.label || '新字段'),
label: String(node?.label || node?.key || '新字段'),
type,
enabled: node?.enabled ?? true,
editable: node?.editable ?? true,
maxLength: Number(pick(node, 'maxLength', 'max_length', type === 'flow' ? 800 : 1000) || (type === 'flow' ? 800 : 1000)),
};
if (type === 'object') {
normalized.children = Array.isArray(node?.children) ? node.children.map(normalizeNode) : [];
} else if (type === 'flow') {
normalized.contentKey = String(pick(node, 'contentKey', 'content_key', node?.key === '镜头流程' ? '镜头内容' : '动作内容'));
const aliases = pick<any[]>(node, 'contentAliases', 'content_aliases', []);
normalized.contentAliases = Array.isArray(aliases) ? aliases.map(item => String(item)).filter(Boolean) : [];
const itemFields = pick<any[]>(node, 'itemFields', 'item_fields', []);
normalized.itemFields = Array.isArray(itemFields) && itemFields.length > 0
? itemFields.map(normalizeFlowField)
: [{ key: normalized.contentKey, label: normalized.contentKey, enabled: true, editable: true, maxLength: normalized.maxLength || 800, value: '' }];
} else {
normalized.value = node?.value ?? (type === 'array' ? [] : type === 'boolean' ? false : type === 'number' ? 0 : '无');
}
return normalized;
}
function normalizeTimeRule(rule: any): VideoPromptTimePlanRule {
const rawSegments = Array.isArray(rule?.segments) ? rule.segments : [];
const rawRatios = Array.isArray(rule?.ratios) ? rule.ratios : [];
return {
minDuration: Number(pick(rule, 'minDuration', 'min_duration', 1) || 1),
maxDuration: Number(pick(rule, 'maxDuration', 'max_duration', 5) || 5),
ratios: rawRatios.map((item: any) => Number(item || 0)),
segments: rawSegments.map((segment: any) => ({
stage: String(segment?.stage || '新阶段'),
description: String(segment?.description || segment?.desc || '阶段说明'),
})),
};
}
function normalizeConfig(data: any): VideoPromptSchemaConfigData {
const rawSections = Array.isArray(data?.sections) ? data.sections : [];
const rawRules = Array.isArray(data?.timePlanRules)
? data.timePlanRules
: Array.isArray(data?.time_plan_rules)
? data.time_plan_rules
: [];
return {
version: data?.version || 'video_prompt_schema_config_v1',
enabled: data?.enabled ?? true,
sections: rawSections.map(normalizeNode),
timePlanRules: rawRules.map(normalizeTimeRule),
};
}
function toApiNode(node: VideoPromptSchemaNode): any {
const base: any = {
key: node.key,
label: node.label,
type: node.type,
enabled: !!node.enabled,
editable: !!node.editable,
max_length: node.maxLength,
};
if (node.type === 'object') {
base.children = (node.children || []).map(toApiNode);
} else if (node.type === 'flow') {
base.content_key = node.contentKey;
base.content_aliases = node.contentAliases || [];
base.item_fields = (node.itemFields || []).map(field => ({
key: field.key,
label: field.label,
enabled: !!field.enabled,
editable: !!field.editable,
max_length: field.maxLength,
value: field.value ?? '',
}));
} else {
base.value = node.value;
}
return base;
}
function toApiConfig(config: VideoPromptSchemaConfigData, enabled: boolean): any {
return {
version: config.version,
enabled,
sections: (config.sections || []).map(toApiNode),
time_plan_rules: (config.timePlanRules || []).map(rule => ({
min_duration: rule.minDuration,
max_duration: rule.maxDuration,
ratios: rule.ratios || [],
segments: (rule.segments || []).map(segment => ({
stage: segment.stage,
description: segment.description,
})),
})),
};
}
function runtimePreviewSchema(result: any): any {
return result?.runtimeSchema || result?.runtime_schema || {};
}
function runtimePreviewTimePlan(result: any): any[] {
const value = result?.timePlan || result?.time_plan || runtimePreviewSchema(result)?. || [];
return Array.isArray(value) ? value : [];
}
function runtimePreviewSnapshot(result: any): any {
return result?.schemaConfigSnapshot || result?.schema_config_snapshot || {};
}
function downloadJson(filename: string, data: any): void {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
const createNode = (type: VideoPromptSchemaNodeType = 'string'): VideoPromptSchemaNode => ({
key: '新字段',
label: '新字段',
type,
enabled: true,
editable: true,
maxLength: type === 'flow' ? 800 : 1000,
value: type === 'array' ? [] : type === 'boolean' ? false : type === 'number' ? 0 : '无',
children: type === 'object' ? [] : undefined,
contentKey: type === 'flow' ? '动作内容' : undefined,
contentAliases: type === 'flow' ? ['动作内容', '动作', '动作说明', '内容', '说明'] : undefined,
itemFields: type === 'flow' ? [{ key: '动作内容', label: '动作内容', enabled: true, editable: true, maxLength: 800, value: '' }] : undefined,
});
const createTimeRule = (): VideoPromptTimePlanRule => ({
minDuration: 1,
maxDuration: 5,
ratios: [0.2, 0.4, 0.4],
segments: [
{ stage: '开场吸引', description: '快速吸引注意力' },
{ stage: '核心展示', description: '展示主体动作、核心卖点或主要视觉内容' },
{ stage: '收尾引导', description: '给出行动引导并稳定落版' },
],
});
const AdminVideoPromptSchemaConfig: React.FC = () => {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [enabled, setEnabled] = useState(true);
const [usingDefault, setUsingDefault] = useState(false);
const [config, setConfig] = useState<VideoPromptSchemaConfigData | null>(null);
const [defaultConfig, setDefaultConfig] = useState<VideoPromptSchemaConfigData | null>(null);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewResult, setPreviewResult] = useState<any>(null);
const [previewForm] = Form.useForm();
const importInputRef = useRef<HTMLInputElement | null>(null);
const sections = config?.sections || [];
const timePlanRules = config?.timePlanRules || [];
const load = async () => {
setLoading(true);
try {
const res = await getVideoPromptSchemaConfig();
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
setConfig(normalizeConfig(res.data));
setDefaultConfig(normalizeConfig((res as any).defaultData ?? (res as any).default_data));
} catch (error: any) {
message.error(error?.message || '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
const updateConfig = (updater: (draft: VideoPromptSchemaConfigData) => void) => {
setConfig(prev => {
const next = normalizeConfig(clone(prev));
updater(next);
return next;
});
};
const save = async () => {
if (!config) return;
setSaving(true);
try {
const res = await saveVideoPromptSchemaConfig({ is_enabled: enabled, data: toApiConfig(config, enabled) });
setConfig(normalizeConfig(res.data));
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
message.success('保存成功');
} catch (error: any) {
message.error(error?.message || '保存失败');
} finally {
setSaving(false);
}
};
const resetDefault = async () => {
setSaving(true);
try {
const res = await resetVideoPromptSchemaConfig();
setConfig(normalizeConfig(res.data));
setDefaultConfig(normalizeConfig((res as any).defaultData ?? (res as any).default_data));
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
message.success('已恢复默认配置');
} catch (error: any) {
message.error(error?.message || '恢复默认失败');
} finally {
setSaving(false);
}
};
const doExport = async () => {
try {
const res = await exportVideoPromptSchemaConfig();
downloadJson('video_prompt_schema_config.json', res);
} catch (error: any) {
message.error(error?.message || '导出失败');
}
};
const onImportFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
try {
const text = await file.text();
const json = JSON.parse(text);
const data = json.data || json;
const isEnabled = json.isEnabled ?? json.is_enabled ?? enabled;
const res = await importVideoPromptSchemaConfig({ is_enabled: !!isEnabled, data });
setConfig(normalizeConfig(res.data));
setEnabled(Boolean((res as any).isEnabled ?? (res as any).is_enabled ?? res.data?.enabled ?? false));
setUsingDefault(Boolean((res as any).usingDefault ?? (res as any).using_default ?? false));
message.success('导入成功');
} catch (error: any) {
message.error(error?.message || '导入失败,请检查 JSON 格式');
}
};
const openPreview = () => {
previewForm.setFieldsValue({ duration: 8, aspectRatio: '9:16', resolution: '1080p', frameRate: '30fps' });
setPreviewResult(null);
setPreviewOpen(true);
};
const doPreview = async () => {
if (!config) return;
const values = await previewForm.validateFields();
try {
const res = await previewVideoPromptSchemaConfig({
is_enabled: enabled,
data: toApiConfig(config, enabled),
duration: Number(values.duration || 8),
aspect_ratio: values.aspectRatio,
resolution: values.resolution,
frame_rate: values.frameRate,
});
setPreviewResult(res);
} catch (error: any) {
message.error(error?.message || '预览失败');
}
};
const addSection = () => {
updateConfig(draft => {
draft.sections.push(createNode('object'));
});
};
const updateSection = (index: number, patch: Partial<VideoPromptSchemaNode>) => {
updateConfig(draft => {
draft.sections[index] = { ...draft.sections[index], ...patch };
if (patch.type === 'object') {
draft.sections[index].children = draft.sections[index].children || [];
}
if (patch.type === 'flow') {
draft.sections[index].contentKey = draft.sections[index].contentKey || '动作内容';
draft.sections[index].contentAliases = draft.sections[index].contentAliases || ['动作内容', '动作', '动作说明', '内容', '说明'];
draft.sections[index].itemFields = draft.sections[index].itemFields || [{ key: '动作内容', label: '动作内容', enabled: true, editable: true, maxLength: 800, value: '' }];
}
});
};
const deleteSection = (index: number) => {
updateConfig(draft => {
draft.sections.splice(index, 1);
});
};
const addChild = (sectionIndex: number) => {
updateConfig(draft => {
const section = draft.sections[sectionIndex];
section.children = section.children || [];
section.children.push(createNode('string'));
});
};
const updateChild = (sectionIndex: number, childIndex: number, patch: Partial<VideoPromptSchemaNode>) => {
updateConfig(draft => {
const children = draft.sections[sectionIndex].children || [];
children[childIndex] = { ...children[childIndex], ...patch };
draft.sections[sectionIndex].children = children;
});
};
const deleteChild = (sectionIndex: number, childIndex: number) => {
updateConfig(draft => {
draft.sections[sectionIndex].children?.splice(childIndex, 1);
});
};
const addFlowField = (sectionIndex: number) => {
updateConfig(draft => {
const section = draft.sections[sectionIndex];
section.itemFields = section.itemFields || [];
section.itemFields.push({ key: '新流程字段', label: '新流程字段', enabled: true, editable: true, maxLength: 800, value: '' });
});
};
const updateFlowField = (sectionIndex: number, fieldIndex: number, patch: Partial<VideoPromptFlowItemField>) => {
updateConfig(draft => {
const fields = draft.sections[sectionIndex].itemFields || [];
fields[fieldIndex] = { ...fields[fieldIndex], ...patch };
draft.sections[sectionIndex].itemFields = fields;
});
};
const deleteFlowField = (sectionIndex: number, fieldIndex: number) => {
updateConfig(draft => {
draft.sections[sectionIndex].itemFields?.splice(fieldIndex, 1);
});
};
const addTimeRule = () => {
updateConfig(draft => {
draft.timePlanRules.push(createTimeRule());
});
};
const updateTimeRule = (index: number, patch: Partial<VideoPromptTimePlanRule>) => {
updateConfig(draft => {
draft.timePlanRules[index] = { ...draft.timePlanRules[index], ...patch };
});
};
const deleteTimeRule = (index: number) => {
updateConfig(draft => {
draft.timePlanRules.splice(index, 1);
});
};
const updateSegment = (ruleIndex: number, segmentIndex: number, patch: Partial<{ stage: string; description: string; ratio: number }>) => {
updateConfig(draft => {
const rule = draft.timePlanRules[ruleIndex];
if (patch.ratio !== undefined) rule.ratios[segmentIndex] = Number(patch.ratio || 0);
if (patch.stage !== undefined) rule.segments[segmentIndex].stage = patch.stage;
if (patch.description !== undefined) rule.segments[segmentIndex].description = patch.description;
});
};
const addSegment = (ruleIndex: number) => {
updateConfig(draft => {
const rule = draft.timePlanRules[ruleIndex];
rule.ratios.push(1);
rule.segments.push({ stage: '新阶段', description: '阶段说明' });
});
};
const deleteSegment = (ruleIndex: number, segmentIndex: number) => {
updateConfig(draft => {
const rule = draft.timePlanRules[ruleIndex];
rule.ratios.splice(segmentIndex, 1);
rule.segments.splice(segmentIndex, 1);
});
};
const sectionItems = useMemo(() => sections.map((section, sectionIndex) => ({
key: `${section.key}-${sectionIndex}`,
label: (
<Space>
<Text strong>{section.label || section.key || '未命名分组'}</Text>
{section.key && section.key !== section.label && <Tag color="blue">key: {section.key}</Tag>}
<Tag>{section.type}</Tag>
{!section.enabled && <Tag color="default"></Tag>}
</Space>
),
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Row gutter={12} align="middle">
<Col span={4}><Input addonBefore="key" value={section.key} maxLength={64} placeholder="传给 AI 的字段名" onChange={e => updateSection(sectionIndex, { key: e.target.value })} /></Col>
<Col span={4}><Input addonBefore="label" value={section.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateSection(sectionIndex, { label: e.target.value })} /></Col>
<Col span={4}>
<Select value={section.type as any} options={NODE_TYPES} style={{ width: '100%' }} onChange={value => updateSection(sectionIndex, { type: value })} />
</Col>
<Col span={3}><Space><Switch checked={section.enabled} onChange={checked => updateSection(sectionIndex, { enabled: checked })} /></Space></Col>
<Col span={3}><Space><Switch checked={section.editable} onChange={checked => updateSection(sectionIndex, { editable: checked })} /></Space></Col>
<Col span={4}><InputNumber min={1} max={20000} value={section.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateSection(sectionIndex, { maxLength: Number(value || 1000) })} /></Col>
<Col span={2} style={{ textAlign: 'right' }}>
<Popconfirm title="确认删除该分组?" onConfirm={() => deleteSection(sectionIndex)}>
<Button danger icon={<DeleteOutlined />} />
</Popconfirm>
</Col>
</Row>
{section.type === 'object' && (
<Card size="small" title="对象字段" extra={<Button size="small" icon={<PlusOutlined />} onClick={() => addChild(sectionIndex)}></Button>}>
<Space direction="vertical" style={{ width: '100%' }}>
{(section.children || []).map((child, childIndex) => (
<Row gutter={8} align="middle" key={`${child.key}-${childIndex}`}>
<Col span={4}><Input addonBefore="key" value={child.key} maxLength={64} placeholder="传给 AI 的字段名" onChange={e => updateChild(sectionIndex, childIndex, { key: e.target.value })} /></Col>
<Col span={4}><Input addonBefore="label" value={child.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateChild(sectionIndex, childIndex, { label: e.target.value })} /></Col>
<Col span={3}><Select value={child.type as any} options={NODE_TYPES.filter(item => item.value !== 'object' && item.value !== 'flow')} style={{ width: '100%' }} onChange={value => updateChild(sectionIndex, childIndex, { type: value })} /></Col>
<Col span={2}><Space><Switch checked={child.enabled} onChange={checked => updateChild(sectionIndex, childIndex, { enabled: checked })} /></Space></Col>
<Col span={2}><Space><Switch checked={child.editable} onChange={checked => updateChild(sectionIndex, childIndex, { editable: checked })} /></Space></Col>
<Col span={3}><InputNumber min={1} max={20000} value={child.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateChild(sectionIndex, childIndex, { maxLength: Number(value || 1000) })} /></Col>
<Col span={5}>
<Input value={Array.isArray(child.value) ? child.value.join('/') : String(child.value ?? '')} maxLength={child.maxLength || 1000} placeholder="默认值" onChange={e => updateChild(sectionIndex, childIndex, { value: child.type === 'array' ? e.target.value.split('/').filter(Boolean) : e.target.value })} />
</Col>
<Col span={1} style={{ textAlign: 'right' }}>
<Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteChild(sectionIndex, childIndex)} />
</Col>
</Row>
))}
</Space>
</Card>
)}
{section.type === 'flow' && (
<Card size="small" title="流程数组对象字段" extra={<Button size="small" icon={<PlusOutlined />} onClick={() => addFlowField(sectionIndex)}></Button>}>
<Row gutter={12} style={{ marginBottom: 12 }}>
<Col span={6}><Input addonBefore="内容字段" value={section.contentKey} maxLength={64} onChange={e => updateSection(sectionIndex, { contentKey: e.target.value })} /></Col>
<Col span={18}><Input addonBefore="别名 / 分隔" value={(section.contentAliases || []).join('/')} onChange={e => updateSection(sectionIndex, { contentAliases: e.target.value.split('/').map(item => item.trim()).filter(Boolean) })} /></Col>
</Row>
<Space direction="vertical" style={{ width: '100%' }}>
{(section.itemFields || []).map((field, fieldIndex) => (
<Row gutter={8} align="middle" key={`${field.key}-${fieldIndex}`}>
<Col span={5}><Input addonBefore="key" value={field.key} maxLength={64} placeholder="流程对象字段名" onChange={e => updateFlowField(sectionIndex, fieldIndex, { key: e.target.value })} /></Col>
<Col span={5}><Input addonBefore="label" value={field.label} maxLength={64} placeholder="后台显示名称" onChange={e => updateFlowField(sectionIndex, fieldIndex, { label: e.target.value })} /></Col>
<Col span={3}><Space><Switch checked={field.enabled} onChange={checked => updateFlowField(sectionIndex, fieldIndex, { enabled: checked })} /></Space></Col>
<Col span={3}><Space><Switch checked={field.editable} onChange={checked => updateFlowField(sectionIndex, fieldIndex, { editable: checked })} /></Space></Col>
<Col span={4}><InputNumber min={1} max={20000} value={field.maxLength} addonBefore="长度" style={{ width: '100%' }} onChange={value => updateFlowField(sectionIndex, fieldIndex, { maxLength: Number(value || 800) })} /></Col>
<Col span={3}><Input value={String(field.value ?? '')} maxLength={field.maxLength || 800} placeholder="默认值" onChange={e => updateFlowField(sectionIndex, fieldIndex, { value: e.target.value })} /></Col>
<Col span={1} style={{ textAlign: 'right' }}><Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteFlowField(sectionIndex, fieldIndex)} /></Col>
</Row>
))}
</Space>
</Card>
)}
{section.type !== 'object' && section.type !== 'flow' && (
<Card size="small" title="默认值">
<TextArea rows={3} value={Array.isArray(section.value) ? section.value.join('\n') : String(section.value ?? '')} maxLength={section.maxLength || 1000} showCount onChange={e => updateSection(sectionIndex, { value: section.type === 'array' ? e.target.value.split('\n').filter(Boolean) : e.target.value })} />
</Card>
)}
</Space>
),
})), [sections]);
if (loading || !config) {
return <Spin />;
}
return (
<div style={{ padding: 24 }}>
<Card>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Row justify="space-between" align="middle">
<Col>
<Title level={3} style={{ margin: 0 }}> Schema </Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
4 AI AI schema
</Paragraph>
</Col>
<Col>
<Space>
<Text></Text>
<Switch checked={enabled} onChange={setEnabled} />
{usingDefault && <Tag color="orange">使</Tag>}
{defaultConfig && <Tag> {defaultConfig.sections.length}</Tag>}
</Space>
</Col>
</Row>
<Alert
type="info"
showIcon
message="长度限制策略"
description="只对管理后台字段和用户可编辑字段做长度校验,不会自动裁剪 AI 生成结果或第 5 步最终视频 prompt。若供应商因为 prompt 过长拒绝,会按真实失败进入现有日志/退款/重试流程。"
/>
<Alert
type="warning"
showIcon
message="预览说明"
description="预览会跟随右上角「启用自定义配置」开关。关闭时会按默认 CLIENT_SCHEMA_V1 兜底预览;打开后才会按当前页面编辑的 sections 和秒数切片规则预览。"
/>
<Space wrap>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={save}></Button>
<Button icon={<EyeOutlined />} onClick={openPreview}> Schema</Button>
<Button icon={<DownloadOutlined />} onClick={doExport}> JSON</Button>
<Button icon={<UploadOutlined />} onClick={() => importInputRef.current?.click()}> JSON</Button>
<Popconfirm title="确认恢复默认配置?" description="会用 CLIENT_SCHEMA_V1 生成的默认配置覆盖当前配置。" onConfirm={resetDefault}>
<Button icon={<ReloadOutlined />}></Button>
</Popconfirm>
<input ref={importInputRef} type="file" accept="application/json,.json" style={{ display: 'none' }} onChange={onImportFile} />
</Space>
<Tabs
items={[
{
key: 'fields',
label: 'Schema 字段配置',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Alert
type="warning"
showIcon
message="字段说明"
description="key 是最终传给 AI 和保存到 prompt_schema 的真实字段名,例如「素材理解」「动作流程」;label 只用于管理后台显示,帮助运营/管理员理解这个字段,不会传给 AI;启用关闭后该字段不会进入运行时 schema;可编辑关闭后用户在第 4 步修改时不能改这个字段。"
/>
<Button icon={<PlusOutlined />} onClick={addSection}></Button>
<Collapse items={sectionItems} />
</Space>
),
},
{
key: 'time',
label: '秒数切片规则',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Alert
type="info"
showIcon
message="切片规则说明"
description="这里决定指定视频时长会被拆成几段,以及每段的阶段名称和说明。运行时会同步生成「动态时间规划」「动作流程」「镜头流程」三个数组,三者时间段保持一致。"
/>
<Button icon={<PlusOutlined />} onClick={addTimeRule}></Button>
{timePlanRules.map((rule, ruleIndex) => (
<Card
key={ruleIndex}
size="small"
title={`${rule.minDuration}-${rule.maxDuration} 秒:${rule.segments.length}`}
extra={<Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteTimeRule(ruleIndex)}></Button>}
>
<Row gutter={12} style={{ marginBottom: 12 }}>
<Col span={4}><InputNumber min={1} value={rule.minDuration} addonBefore="最小秒" style={{ width: '100%' }} onChange={value => updateTimeRule(ruleIndex, { minDuration: Number(value || 1) })} /></Col>
<Col span={4}><InputNumber min={1} value={rule.maxDuration} addonBefore="最大秒" style={{ width: '100%' }} onChange={value => updateTimeRule(ruleIndex, { maxDuration: Number(value || 1) })} /></Col>
<Col span={16}><Button size="small" icon={<PlusOutlined />} onClick={() => addSegment(ruleIndex)}></Button></Col>
</Row>
<Space direction="vertical" style={{ width: '100%' }}>
{rule.segments.map((segment, segmentIndex) => (
<Row gutter={8} align="middle" key={segmentIndex}>
<Col span={3}><InputNumber min={0.01} step={0.01} value={rule.ratios[segmentIndex]} addonBefore="比例" style={{ width: '100%' }} onChange={value => updateSegment(ruleIndex, segmentIndex, { ratio: Number(value || 0.01) })} /></Col>
<Col span={5}><Input value={segment.stage} maxLength={64} placeholder="阶段" onChange={e => updateSegment(ruleIndex, segmentIndex, { stage: e.target.value })} /></Col>
<Col span={15}><Input value={segment.description} maxLength={300} placeholder="说明" onChange={e => updateSegment(ruleIndex, segmentIndex, { description: e.target.value })} /></Col>
<Col span={1}><Button danger size="small" icon={<DeleteOutlined />} onClick={() => deleteSegment(ruleIndex, segmentIndex)} /></Col>
</Row>
))}
</Space>
</Card>
))}
</Space>
),
},
{
key: 'json',
label: 'JSON 高级查看',
children: <TextArea rows={24} value={JSON.stringify(toApiConfig(config, enabled), null, 2)} readOnly />,
},
]}
/>
</Space>
</Card>
<Modal title="预览运行时 Schema" open={previewOpen} onCancel={() => setPreviewOpen(false)} width={980} footer={<Space><Button onClick={() => setPreviewOpen(false)}></Button><Button type="primary" onClick={doPreview}></Button></Space>}>
<Form form={previewForm} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item name="duration" label="时长" rules={[{ required: true }]}><InputNumber min={1} max={3600} /></Form.Item>
<Form.Item name="aspectRatio" label="比例" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="resolution" label="清晰度" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="frameRate" label="帧率" rules={[{ required: true }]}><Input /></Form.Item>
</Form>
<Divider />
{previewResult ? (
<Tabs
items={[
{
key: 'runtime',
label: '运行时 Schema',
children: <TextArea rows={24} value={JSON.stringify(runtimePreviewSchema(previewResult), null, 2)} readOnly />,
},
{
key: 'timePlan',
label: `动态时间规划(${runtimePreviewTimePlan(previewResult).length} 段)`,
children: <TextArea rows={24} value={JSON.stringify(runtimePreviewTimePlan(previewResult), null, 2)} readOnly />,
},
{
key: 'snapshot',
label: '配置快照',
children: <TextArea rows={24} value={JSON.stringify(runtimePreviewSnapshot(previewResult), null, 2)} readOnly />,
},
]}
/>
) : (
<Alert type="info" showIcon message="点击生成预览后,可以看到传给 AI 的纯 schema、动态时间规划和本次配置快照。" />
)}
</Modal>
</div>
);
};
export default AdminVideoPromptSchemaConfig;
+88
View File
@@ -581,3 +581,91 @@ export interface AdminShotSegmentQueryParams {
page?: number;
pageSize?: number;
}
// ── Video Prompt Schema Config (Admin) ─────────────────────
export type VideoPromptSchemaNodeType = 'object' | 'array' | 'string' | 'number' | 'boolean' | 'flow';
export interface VideoPromptSchemaNode {
key: string;
label: string;
type: VideoPromptSchemaNodeType | string;
enabled: boolean;
editable: boolean;
maxLength?: number;
value?: any;
children?: VideoPromptSchemaNode[];
contentKey?: string;
contentAliases?: string[];
itemFields?: VideoPromptFlowItemField[];
}
export interface VideoPromptFlowItemField {
key: string;
label: string;
enabled: boolean;
editable: boolean;
maxLength?: number;
value?: any;
}
export interface VideoPromptTimePlanSegment {
stage: string;
description: string;
}
export interface VideoPromptTimePlanRule {
minDuration: number;
maxDuration: number;
ratios: number[];
segments: VideoPromptTimePlanSegment[];
}
export interface VideoPromptSchemaConfigData {
version: string;
enabled: boolean;
sections: VideoPromptSchemaNode[];
timePlanRules: VideoPromptTimePlanRule[];
}
export interface VideoPromptSchemaConfigOut {
id?: string | null;
key: string;
description?: string | null;
isEnabled: boolean;
usingDefault: boolean;
data: VideoPromptSchemaConfigData;
defaultData: VideoPromptSchemaConfigData;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface VideoPromptSchemaConfigSavePayload {
is_enabled: boolean;
data: any;
}
export interface VideoPromptSchemaExportOut {
version: string;
key: string;
isEnabled: boolean;
data: VideoPromptSchemaConfigData;
}
export interface VideoPromptSchemaPreviewPayload {
is_enabled: boolean;
data: any;
duration: number;
aspect_ratio: string;
resolution: string;
frame_rate: string;
supported_durations?: number[];
supported_ratios?: string[];
supported_resolutions?: string[];
}
export interface VideoPromptSchemaPreviewOut {
schemaConfigSnapshot: Record<string, any>;
runtimeSchema: Record<string, any>;
timePlan: Record<string, any>[];
}
+6
View File
@@ -0,0 +1,6 @@
from fastapi import APIRouter
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
router = APIRouter()
router.include_router(video_prompt_schema_config_router)
@@ -0,0 +1,81 @@
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.user import User
from app.schemas.video_prompt_schema_config import (
VideoPromptSchemaConfigImportRequest,
VideoPromptSchemaConfigOut,
VideoPromptSchemaConfigSaveRequest,
VideoPromptSchemaExportOut,
VideoPromptSchemaPreviewOut,
VideoPromptSchemaPreviewRequest,
)
from app.services.video_prompt_schema_config_service import (
export_video_prompt_schema_config,
get_video_prompt_schema_config,
import_video_prompt_schema_config,
preview_runtime_schema,
reset_video_prompt_schema_config,
save_video_prompt_schema_config,
)
router = APIRouter(prefix="/admin/video-prompt-schema-config", tags=["admin-video-prompt-schema-config"])
@router.get("", response_model=VideoPromptSchemaConfigOut, summary="获取视频提词 Schema 配置")
async def get_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
_ = admin
return await get_video_prompt_schema_config(db)
@router.put("", response_model=VideoPromptSchemaConfigOut, summary="保存视频提词 Schema 配置")
async def save_config(
req: VideoPromptSchemaConfigSaveRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
_ = admin
return await save_video_prompt_schema_config(db, data=req.data, is_enabled=req.is_enabled)
@router.post("/reset-default", response_model=VideoPromptSchemaConfigOut, summary="恢复默认视频提词 Schema 配置")
async def reset_default(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
_ = admin
return await reset_video_prompt_schema_config(db)
@router.get("/export", response_model=VideoPromptSchemaExportOut, summary="导出视频提词 Schema 配置")
async def export_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
_ = admin
return await export_video_prompt_schema_config(db)
@router.post("/import", response_model=VideoPromptSchemaConfigOut, summary="导入视频提词 Schema 配置")
async def import_config(
req: VideoPromptSchemaConfigImportRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
_ = admin
return await import_video_prompt_schema_config(db, data=req.data, is_enabled=req.is_enabled)
@router.post("/preview", response_model=VideoPromptSchemaPreviewOut, summary="预览视频提词运行时 Schema")
async def preview_config(
req: VideoPromptSchemaPreviewRequest,
admin: User = Depends(get_admin_user),
):
_ = admin
return preview_runtime_schema(data=req.data, is_enabled=req.is_enabled, video_config=req.to_video_config())
+2
View File
@@ -22,6 +22,7 @@ from app.api.v1.user_oauth import router as user_oauth_router
from app.api.v1.user_oauth_app import router as user_oauth_app_router
from app.api.v1.upload_material import router as upload_material_router
from app.api.v1.pre_test_template import router as pre_test_template_router
from app.api.admin import router as admin_module_router
api_router = APIRouter()
api_router.include_router(auth_router)
@@ -46,3 +47,4 @@ api_router.include_router(user_oauth_router)
api_router.include_router(user_oauth_app_router)
api_router.include_router(upload_material_router)
api_router.include_router(pre_test_template_router)
api_router.include_router(admin_module_router)
+21
View File
@@ -55,3 +55,24 @@ class ModulePromptTypeEnum(StrEnum):
IMAGE_PROMPT = "image_prompt"
VIDEO_PROMPT = "video_prompt"
# 视频提词 Schema 管理后台配置
VIDEO_SCHEMA_CONFIG_KEY = "video_prompt_schema_config"
VIDEO_SCHEMA_CONFIG_DESCRIPTION = "视频提词 Schema 管理后台配置"
VIDEO_SCHEMA_CONFIG_VERSION = "video_prompt_schema_config_v1"
VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE = "default"
VIDEO_SCHEMA_CONFIG_DATABASE_SOURCE = "database"
# 仅用于管理后台配置项和用户可编辑字段校验;AI 生成结果与最终视频 prompt 不做自动裁剪。
VIDEO_SCHEMA_CONFIG_KEY_MAX_LEN = 64
VIDEO_SCHEMA_CONFIG_LABEL_MAX_LEN = 64
VIDEO_SCHEMA_CONFIG_DESC_MAX_LEN = 300
VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN = 1000
VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN = 800
VIDEO_SCHEMA_TIME_STAGE_MAX_LEN = 64
VIDEO_SCHEMA_TIME_DESC_MAX_LEN = 300
VIDEO_SCHEMA_MAX_SECTION_COUNT = 40
VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION = 80
VIDEO_SCHEMA_MAX_TIME_RULE_COUNT = 30
VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE = 12
@@ -412,6 +412,9 @@ class HotOpeningVideoGenerationOut(BaseModel):
prompt_schema: dict[str, Any] | None = Field(None, description="视频提词 JSON schema。第5步 ChatGenerationTask 原始提词会使用该 JSON 字符串")
final_prompt: str | None = Field(None, description="视频最终提词,仅用于前端展示")
prompt_params: dict[str, Any] | None = Field(None, description="第4步生成视频提词时使用的视频配置,例如 duration、aspect_ratio、resolution")
schema_config_snapshot: dict[str, Any] | None = Field(None, description="第4步生成视频提词时备份的 VIDEO_SCHEMA 配置快照。修改时按快照判断可编辑字段")
schema_config_source: str | None = Field(None, description="VIDEO_SCHEMA 配置来源:database=后台配置,default=CLIENT_SCHEMA_V1 默认配置")
schema_config_version: str | None = Field(None, description="VIDEO_SCHEMA 配置版本")
engine_id: str | None = Field(None, description="视频生成引擎ID")
engine_name: str | None = Field(None, description="视频生成引擎名称")
params: dict[str, Any] | None = Field(None, description="视频生成实际参数。第5步只传 engine_id,其它参数继承第4步")
@@ -415,6 +415,9 @@ class ShotReplicateVideoGenerationOut(BaseModel):
prompt_schema: dict[str, Any] | None = Field(None, description="视频提词 JSON schema。第5步 ChatGenerationTask 原始提词会使用该 JSON 字符串")
final_prompt: str | None = Field(None, description="视频最终提词,仅用于前端展示")
prompt_params: dict[str, Any] | None = Field(None, description="第4步生成视频提词时使用的视频配置,例如 duration、aspect_ratio、resolution")
schema_config_snapshot: dict[str, Any] | None = Field(None, description="第4步生成视频提词时备份的 VIDEO_SCHEMA 配置快照。修改时按快照判断可编辑字段")
schema_config_source: str | None = Field(None, description="VIDEO_SCHEMA 配置来源:database=后台配置,default=CLIENT_SCHEMA_V1 默认配置")
schema_config_version: str | None = Field(None, description="VIDEO_SCHEMA 配置版本")
engine_id: str | None = Field(None, description="视频生成引擎ID")
engine_name: str | None = Field(None, description="视频生成引擎名称")
params: dict[str, Any] | None = Field(None, description="视频生成实际参数。第5步只传 engine_id,其它参数继承第4步")
@@ -0,0 +1,100 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.enums.common import (
VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN,
VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
VIDEO_SCHEMA_TIME_DESC_MAX_LEN,
VIDEO_SCHEMA_TIME_STAGE_MAX_LEN,
)
from app.schemas.common import NaiveDatetimeOptional
class VideoPromptSchemaConfigSaveRequest(BaseModel):
"""保存管理后台视频提词 Schema 配置。"""
model_config = ConfigDict(extra="forbid")
is_enabled: bool = Field(True, description="是否启用后台自定义配置;关闭后生成时回落默认 CLIENT_SCHEMA_V1")
data: dict[str, Any] = Field(..., description="管理后台配置数据,包含 sections、time_plan_rules 等。管理字段不会传给 AI")
@field_validator("data")
@classmethod
def _validate_data(cls, value: dict[str, Any]) -> dict[str, Any]:
if not isinstance(value, dict) or not value:
raise ValueError("data 必须是非空 JSON 对象")
return value
class VideoPromptSchemaConfigImportRequest(VideoPromptSchemaConfigSaveRequest):
"""导入 JSON 配置。"""
class VideoPromptSchemaPreviewRequest(BaseModel):
"""预览指定配置在某个视频规格下的运行时 schema。"""
model_config = ConfigDict(extra="forbid")
is_enabled: bool = Field(True, description="是否按自定义配置预览;关闭时按默认配置预览")
data: dict[str, Any] = Field(..., description="待预览的管理后台配置 JSON")
duration: int = Field(8, ge=1, le=3600, description="视频时长,单位秒")
aspect_ratio: str = Field("9:16", min_length=1, max_length=16, description="视频比例,例如 9:16")
resolution: str = Field("1080p", min_length=1, max_length=32, description="清晰度,例如 720p/1080p")
frame_rate: str = Field("30fps", min_length=1, max_length=32, description="帧率,例如 30fps")
supported_durations: list[int] = Field(default_factory=list, description="支持时长列表,仅用于预览输出规格限制")
supported_ratios: list[str] = Field(default_factory=list, description="支持比例列表,仅用于预览输出规格限制")
supported_resolutions: list[str] = Field(default_factory=list, description="支持分辨率列表,仅用于预览输出规格限制")
@model_validator(mode="after")
def _ensure_data(self) -> "VideoPromptSchemaPreviewRequest":
if not self.data:
raise ValueError("data 不能为空")
return self
def to_video_config(self) -> dict[str, Any]:
return {
"duration": self.duration,
"aspect_ratio": self.aspect_ratio,
"resolution": self.resolution,
"frame_rate": self.frame_rate,
"supported_durations": self.supported_durations,
"supported_ratios": self.supported_ratios,
"supported_resolutions": self.supported_resolutions,
}
class VideoPromptSchemaConfigOut(BaseModel):
"""管理后台视频提词 Schema 配置详情。"""
id: str | None = Field(None, description="system_configs.id;未保存时为空")
key: str = Field(..., description="system_configs.key")
description: str | None = Field(None, description="配置说明")
is_enabled: bool = Field(False, description="当前是否启用数据库自定义配置")
using_default: bool = Field(False, description="当前是否正在使用默认 CLIENT_SCHEMA_V1 兜底")
data: dict[str, Any] = Field(..., description="当前管理后台配置数据")
default_data: dict[str, Any] = Field(..., description="由 CLIENT_SCHEMA_V1 转换得到的默认配置")
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
class VideoPromptSchemaExportOut(BaseModel):
version: str
key: str
is_enabled: bool
data: dict[str, Any]
class VideoPromptSchemaPreviewOut(BaseModel):
schema_config_snapshot: dict[str, Any] = Field(..., description="本次预览使用的配置快照")
runtime_schema: dict[str, Any] = Field(..., description="传给 AI 的纯运行时 schema,不包含 label/editable/enabled 等管理字段")
time_plan: list[dict[str, Any]] = Field(default_factory=list, description="根据秒数切片规则生成的动态时间规划")
class VideoPromptSchemaLengthLimitsOut(BaseModel):
editable_text_max_len: int = VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN
flow_content_max_len: int = VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN
time_stage_max_len: int = VIDEO_SCHEMA_TIME_STAGE_MAX_LEN
time_desc_max_len: int = VIDEO_SCHEMA_TIME_DESC_MAX_LEN
@@ -83,6 +83,7 @@ from app.services.module_generation_step_update_service import (
update_module_video_prompt_schema,
)
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.video_prompt_schema_config_service import get_runtime_schema_snapshot
from app.utils.id_gen import generate_id
MODULE = ModuleCodeEnum.HOT_OPENING_REPLICATE.value
@@ -418,6 +419,9 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
prompt_schema=video_prompt_output.get("prompt_schema"),
final_prompt=video_prompt_output.get("final_prompt"),
prompt_params=video_prompt_output.get("params_used_for_prompt") or video_prompt_input.get("video_config"),
schema_config_snapshot=video_prompt_output.get("schema_config_snapshot"),
schema_config_source=video_prompt_output.get("schema_config_source"),
schema_config_version=video_prompt_output.get("schema_config_version"),
engine_id=video_snapshot.get("id") or video_generate_input.get("engine_id"),
engine_name=video_snapshot.get("name") or video_generate_input.get("engine_name"),
params=video_generate_input.get("params") or video_generate_input,
@@ -1113,6 +1117,8 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
prompt_type=ModulePromptTypeEnum.VIDEO_PROMPT.value,
request=request_log,
)
schema_config_snapshot = await get_runtime_schema_snapshot(db)
request_log["schema_config_source"] = schema_config_snapshot.get("source")
prompt_schema, final_prompt, token_usage = await optimize_hot_opening_video_prompt(
db,
user_id=project.user_id,
@@ -1123,6 +1129,7 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
generated_image_url=generated_image_url,
video_config=video_config,
target_platform=target_platform,
schema_config_snapshot=schema_config_snapshot,
)
billing = await charge_module_prompt_usage(
db,
@@ -1149,6 +1156,9 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
"final_prompt": final_prompt,
"params_used_for_prompt": video_config,
"target_platform": target_platform,
"schema_config_snapshot": schema_config_snapshot,
"schema_config_source": schema_config_snapshot.get("source"),
"schema_config_version": schema_config_snapshot.get("version"),
},
usage=usage,
),
@@ -1164,7 +1174,7 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
module=project.module,
prompt_type=ModulePromptTypeEnum.VIDEO_PROMPT.value,
request=request_log,
response={"prompt_schema": prompt_schema, "final_prompt": final_prompt},
response={"prompt_schema": prompt_schema, "final_prompt": final_prompt, "schema_config_source": schema_config_snapshot.get("source")},
token_usage=usage,
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUCCESS.value, message="视频 AI 提词生成成功")
@@ -10,6 +10,15 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.common import (
VIDEO_SCHEMA_CONFIG_DATABASE_SOURCE,
VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE,
VIDEO_SCHEMA_CONFIG_VERSION,
VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN,
VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
VIDEO_SCHEMA_TIME_DESC_MAX_LEN,
VIDEO_SCHEMA_TIME_STAGE_MAX_LEN,
)
from app.enums.video_prompt_schema import PromptSchemaVersionEnum, VideoPromptSchemaUsageEnum
from app.models.model_config import ModelConfig
from app.models.token_usage import TokenUsage
@@ -157,9 +166,353 @@ def _bounds_to_plan(bounds: list[int], stages: list[tuple[str, str]]) -> list[di
return plan
def build_time_plan(duration: int) -> list[dict[str, str]]:
def _default_time_plan_rules() -> list[dict[str, Any]]:
return [
{
"min_duration": 1,
"max_duration": 5,
"ratios": [0.2, 0.4, 0.4],
"segments": [
{"stage": "开场吸引", "description": "快速建立主体、产品和画面风格"},
{"stage": "核心展示", "description": "展示主体动作、核心卖点或主要视觉内容"},
{"stage": "行动引导", "description": "强化记忆点并给出转化引导"},
],
},
{
"min_duration": 6,
"max_duration": 8,
"ratios": [0.15, 0.3, 0.35, 0.2],
"segments": [
{"stage": "开场吸引", "description": "快速吸引注意力"},
{"stage": "主体展示", "description": "展示主体和产品关系"},
{"stage": "核心卖点", "description": "突出新项目核心内容点"},
{"stage": "收尾引导", "description": "给出行动引导并稳定落版"},
],
},
{
"min_duration": 9,
"max_duration": 15,
"ratios": [0.13, 0.2, 0.27, 0.25, 0.15],
"segments": [
{"stage": "爆款开头", "description": "复刻参考素材开头节奏和视觉吸引点"},
{"stage": "主体建立", "description": "明确新项目主体和产品信息"},
{"stage": "卖点放大", "description": "围绕核心内容点展开动作和镜头"},
{"stage": "情绪推进", "description": "用动作、字幕或镜头变化强化记忆"},
{"stage": "转化收尾", "description": "给出清晰行动引导"},
],
},
]
def _normalize_bool(value: Any, default: bool = True) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() not in {"0", "false", "no", "off", "", "禁用"}
def _normalize_int(value: Any, default: int, *, min_value: int = 1, max_value: int = 100000) -> int:
try:
number = int(value)
except Exception:
number = default
return max(min_value, min(max_value, number))
def _truncate_for_config(value: Any, limit: int) -> str:
text = str(value or "").strip()
return text[:limit]
def _schema_value_type(value: Any) -> str:
if isinstance(value, dict):
return "object"
if isinstance(value, list):
return "array"
if isinstance(value, bool):
return "boolean"
if isinstance(value, (int, float)):
return "number"
return "string"
def _default_node(key: str, value: Any, *, editable: bool = True) -> dict[str, Any]:
node: dict[str, Any] = {
"key": str(key),
"label": str(key),
"type": _schema_value_type(value),
"enabled": True,
"editable": editable,
"max_length": VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN,
}
if isinstance(value, dict):
node["children"] = [_default_node(child_key, child_value, editable=editable) for child_key, child_value in value.items()]
else:
node["value"] = copy.deepcopy(value)
return node
def default_video_prompt_schema_config() -> dict[str, Any]:
sections: list[dict[str, Any]] = []
for key, value in CLIENT_SCHEMA_V1.items():
if key in {"schema_version", "schema_usage"}:
continue
if key == "动作流程":
sections.append(
{
"key": "动作流程",
"label": "动作流程",
"type": "flow",
"enabled": True,
"editable": True,
"content_key": "动作内容",
"content_aliases": list(ACTION_FLOW_CONTENT_KEYS),
"max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
"item_fields": [
{"key": "动作内容", "label": "动作内容", "enabled": True, "editable": True, "max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, "value": ""}
],
}
)
elif key == "镜头流程":
sections.append(
{
"key": "镜头流程",
"label": "镜头流程",
"type": "flow",
"enabled": True,
"editable": True,
"content_key": "镜头内容",
"content_aliases": list(CAMERA_FLOW_CONTENT_KEYS),
"max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
"item_fields": [
{"key": "镜头内容", "label": "镜头内容", "enabled": True, "editable": True, "max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, "value": ""}
],
}
)
else:
sections.append(_default_node(key, value, editable=True))
return {
"version": VIDEO_SCHEMA_CONFIG_VERSION,
"enabled": True,
"sections": sections,
"time_plan_rules": _default_time_plan_rules(),
}
def _extract_config_data(schema_config_snapshot: Any | None) -> dict[str, Any] | None:
if not isinstance(schema_config_snapshot, dict):
return None
data = schema_config_snapshot.get("data")
if isinstance(data, dict):
return data
if schema_config_snapshot.get("sections") or schema_config_snapshot.get("time_plan_rules"):
return schema_config_snapshot
return None
def normalize_video_prompt_schema_config(config: Any | None) -> dict[str, Any]:
source = config if isinstance(config, dict) else {}
default_config = default_video_prompt_schema_config()
if not source:
return default_config
normalized: dict[str, Any] = {
"version": str(source.get("version") or VIDEO_SCHEMA_CONFIG_VERSION),
"enabled": _normalize_bool(source.get("enabled"), True),
"sections": [],
"time_plan_rules": [],
}
raw_sections = source.get("sections") if isinstance(source.get("sections"), list) else default_config["sections"]
for section in raw_sections:
if not isinstance(section, dict):
continue
key = _truncate_for_config(section.get("key") or section.get("label"), 64)
if not key:
continue
section_type = str(section.get("type") or "object")
item: dict[str, Any] = {
"key": key,
"label": _truncate_for_config(section.get("label") or key, 64),
"type": section_type,
"enabled": _normalize_bool(section.get("enabled"), True),
"editable": _normalize_bool(section.get("editable"), True),
"max_length": _normalize_int(section.get("max_length"), VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN, min_value=1, max_value=20000),
}
if section_type == "object":
children = section.get("children") if isinstance(section.get("children"), list) else []
item["children"] = []
for child in children:
if not isinstance(child, dict):
continue
child_key = _truncate_for_config(child.get("key") or child.get("label"), 64)
if not child_key:
continue
item["children"].append(
{
"key": child_key,
"label": _truncate_for_config(child.get("label") or child_key, 64),
"type": str(child.get("type") or _schema_value_type(child.get("value"))),
"enabled": _normalize_bool(child.get("enabled"), True),
"editable": _normalize_bool(child.get("editable"), True),
"max_length": _normalize_int(child.get("max_length"), VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN, min_value=1, max_value=20000),
"value": copy.deepcopy(child.get("value", "")),
}
)
elif section_type == "flow":
content_key = _truncate_for_config(section.get("content_key") or section.get("contentKey") or ("镜头内容" if key == "镜头流程" else "动作内容"), 64)
aliases = section.get("content_aliases") or section.get("contentAliases")
if not isinstance(aliases, list) or not aliases:
aliases = list(CAMERA_FLOW_CONTENT_KEYS if key == "镜头流程" else ACTION_FLOW_CONTENT_KEYS)
item["content_key"] = content_key
item["content_aliases"] = [_truncate_for_config(alias, 64) for alias in aliases if _truncate_for_config(alias, 64)]
raw_fields = section.get("item_fields") or section.get("itemFields")
if not isinstance(raw_fields, list) or not raw_fields:
raw_fields = [{"key": content_key, "label": content_key, "enabled": True, "editable": True, "max_length": item["max_length"], "value": ""}]
item["item_fields"] = []
for field in raw_fields:
if not isinstance(field, dict):
continue
field_key = _truncate_for_config(field.get("key") or field.get("label"), 64)
if not field_key or field_key == "时间段":
continue
item["item_fields"].append(
{
"key": field_key,
"label": _truncate_for_config(field.get("label") or field_key, 64),
"enabled": _normalize_bool(field.get("enabled"), True),
"editable": _normalize_bool(field.get("editable"), True),
"max_length": _normalize_int(field.get("max_length"), item["max_length"], min_value=1, max_value=20000),
"value": copy.deepcopy(field.get("value", "")),
}
)
if not any(field["key"] == content_key for field in item["item_fields"]):
item["item_fields"].insert(0, {"key": content_key, "label": content_key, "enabled": True, "editable": True, "max_length": item["max_length"], "value": ""})
else:
item["value"] = copy.deepcopy(section.get("value", [] if section_type == "array" else ""))
normalized["sections"].append(item)
raw_rules = source.get("time_plan_rules") or source.get("timePlanRules")
if not isinstance(raw_rules, list) or not raw_rules:
raw_rules = default_config["time_plan_rules"]
for rule in raw_rules:
if not isinstance(rule, dict):
continue
segments = rule.get("segments") if isinstance(rule.get("segments"), list) else []
ratios = rule.get("ratios") if isinstance(rule.get("ratios"), list) else []
normalized_segments: list[dict[str, str]] = []
for segment in segments:
if not isinstance(segment, dict):
continue
normalized_segments.append(
{
"stage": _truncate_for_config(segment.get("stage") or "", VIDEO_SCHEMA_TIME_STAGE_MAX_LEN),
"description": _truncate_for_config(segment.get("description") or segment.get("desc") or "", VIDEO_SCHEMA_TIME_DESC_MAX_LEN),
}
)
if not normalized_segments:
continue
try:
normalized_ratios = [float(item) for item in ratios]
except Exception:
normalized_ratios = []
if len(normalized_ratios) != len(normalized_segments):
normalized_ratios = [1 / len(normalized_segments)] * len(normalized_segments)
ratio_total = sum(item for item in normalized_ratios if item > 0)
if ratio_total <= 0:
normalized_ratios = [1 / len(normalized_segments)] * len(normalized_segments)
else:
normalized_ratios = [max(0.01, item) / ratio_total for item in normalized_ratios]
normalized["time_plan_rules"].append(
{
"min_duration": _normalize_int(rule.get("min_duration") or rule.get("minDuration"), 1, min_value=1, max_value=3600),
"max_duration": _normalize_int(rule.get("max_duration") or rule.get("maxDuration"), 15, min_value=1, max_value=3600),
"ratios": normalized_ratios,
"segments": normalized_segments,
}
)
if not normalized["time_plan_rules"]:
normalized["time_plan_rules"] = default_config["time_plan_rules"]
return normalized
def build_client_schema_from_config(schema_config_snapshot: Any | None = None) -> dict[str, Any]:
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config_snapshot))
if not _normalize_bool(data.get("enabled"), True):
data = default_video_prompt_schema_config()
schema: dict[str, Any] = {
"schema_version": PromptSchemaVersionEnum.CLIENT_V1.value,
"schema_usage": VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value,
}
for section in data.get("sections", []):
if not isinstance(section, dict) or not _normalize_bool(section.get("enabled"), True):
continue
key = str(section.get("key") or "").strip()
if not key:
continue
section_type = str(section.get("type") or "object")
if section_type == "object":
obj: dict[str, Any] = {}
for child in section.get("children") or []:
if not isinstance(child, dict) or not _normalize_bool(child.get("enabled"), True):
continue
child_key = str(child.get("key") or "").strip()
if not child_key:
continue
obj[child_key] = copy.deepcopy(child.get("value", ""))
schema[key] = obj
elif section_type == "flow":
schema[key] = []
else:
schema[key] = copy.deepcopy(section.get("value", [] if section_type == "array" else ""))
return schema
def build_schema_config_snapshot(schema_config: Any | None = None, *, source: str = VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE) -> dict[str, Any]:
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config) or schema_config)
if not data:
data = default_video_prompt_schema_config()
return {"version": VIDEO_SCHEMA_CONFIG_VERSION, "source": source, "data": data}
def _select_time_plan_rule(duration: int, schema_config_snapshot: Any | None = None) -> dict[str, Any] | None:
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config_snapshot))
rules = data.get("time_plan_rules") if isinstance(data.get("time_plan_rules"), list) else []
for rule in rules:
if not isinstance(rule, dict):
continue
min_duration = _normalize_int(rule.get("min_duration"), 1, min_value=1, max_value=3600)
max_duration = _normalize_int(rule.get("max_duration"), min_duration, min_value=1, max_value=3600)
if min_duration <= duration <= max_duration:
return rule
return None
def build_time_plan(duration: int, schema_config_snapshot: Any | None = None) -> list[dict[str, str]]:
duration = max(1, int(duration))
if duration <= 5:
rule = _select_time_plan_rule(duration, schema_config_snapshot)
if rule:
segments = rule.get("segments") if isinstance(rule.get("segments"), list) else []
ratios = rule.get("ratios") if isinstance(rule.get("ratios"), list) else []
stages: list[tuple[str, str]] = []
for segment in segments:
if isinstance(segment, dict):
stages.append((str(segment.get("stage") or ""), str(segment.get("description") or "")))
if stages:
try:
ratio_values = [float(item) for item in ratios]
except Exception:
ratio_values = []
if len(ratio_values) != len(stages):
ratio_values = [1 / len(stages)] * len(stages)
ratio_total = sum(item for item in ratio_values if item > 0)
if ratio_total <= 0:
ratio_values = [1 / len(stages)] * len(stages)
else:
ratio_values = [max(0.01, item) / ratio_total for item in ratio_values]
return _bounds_to_plan(_build_scaled_bounds(duration, ratio_values), stages)
# fallback 保留旧规则,避免配置为空或异常时影响原流程。
return _bounds_to_plan(
_build_scaled_bounds(duration, [0.2, 0.4, 0.4]),
[
@@ -167,9 +520,8 @@ def build_time_plan(duration: int) -> list[dict[str, str]]:
("核心展示", "展示主体动作、核心卖点或主要视觉内容"),
("行动引导", "强化记忆点并给出转化引导"),
],
)
if duration <= 8:
return _bounds_to_plan(
) if duration <= 5 else (
_bounds_to_plan(
_build_scaled_bounds(duration, [0.15, 0.3, 0.35, 0.2]),
[
("开场吸引", "快速吸引注意力"),
@@ -177,8 +529,7 @@ def build_time_plan(duration: int) -> list[dict[str, str]]:
("核心卖点", "突出新项目核心内容点"),
("收尾引导", "给出行动引导并稳定落版"),
],
)
return _bounds_to_plan(
) if duration <= 8 else _bounds_to_plan(
_build_scaled_bounds(duration, [0.13, 0.2, 0.27, 0.25, 0.15]),
[
("爆款开头", "复刻参考素材开头节奏和视觉吸引点"),
@@ -188,17 +539,22 @@ def build_time_plan(duration: int) -> list[dict[str, str]]:
("转化收尾", "给出清晰行动引导"),
],
)
)
def build_dynamic_schema(video_config: dict[str, Any]) -> dict[str, Any]:
schema = copy.deepcopy(CLIENT_SCHEMA_V1)
def build_dynamic_schema(video_config: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
schema = build_client_schema_from_config(schema_config_snapshot)
duration = int(video_config["duration"])
video_ratio = str(video_config["aspect_ratio"])
resolution = str(video_config["resolution"])
frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE)
recommended_resolution = get_recommended_resolution(video_ratio, resolution)
schema["画面属性"].update(
frame = schema.setdefault("画面属性", {})
if not isinstance(frame, dict):
frame = {}
schema["画面属性"] = frame
frame.update(
{
"视频时长": f"{duration}",
"视频比例": video_ratio,
@@ -207,7 +563,24 @@ def build_dynamic_schema(video_config: dict[str, Any]) -> dict[str, Any]:
"帧率": frame_rate,
}
)
schema["动态时间规划"] = build_time_plan(duration)
time_plan = build_time_plan(duration, schema_config_snapshot)
schema["动态时间规划"] = time_plan
# 预览/AI 入参阶段也要把流程数组按秒数切片规则初始化出来。
# 这样管理后台配置了 4 段/5 段规则后,动作流程、镜头流程会和动态时间规划保持相同长度,
# AI 生成时也能明确知道每个时间段需要填哪个流程字段。
for flow_key in ("动作流程", "镜头流程"):
if flow_key not in schema:
continue
content_key = _flow_content_key(schema_config_snapshot, flow_key)
schema[flow_key] = _align_flow_time_ranges(
schema.get(flow_key),
time_plan,
content_key,
content_keys=_flow_content_aliases(schema_config_snapshot, flow_key),
item_fields=_flow_item_field_rules(schema_config_snapshot, flow_key),
)
schema["输出规格限制"] = {
"支持时长": _safe_list(video_config.get("supported_durations")),
"支持比例": _safe_list(video_config.get("supported_ratios")),
@@ -255,6 +628,7 @@ def build_user_text(
references: list[dict[str, str]],
video_config: dict[str, Any],
client_schema: dict[str, Any],
schema_config_snapshot: Any | None = None,
) -> str:
duration = int(video_config["duration"])
video_ratio = str(video_config["aspect_ratio"])
@@ -286,7 +660,7 @@ def build_user_text(
"参考素材": references,
"输出要求": {
"生成类型": infer_generation_type(references),
"必须填充动态时间规划": build_time_plan(duration),
"必须填充动态时间规划": build_time_plan(duration, schema_config_snapshot),
"必须填充动作流程": "动作流程时间段必须覆盖完整视频时长",
"必须填充镜头流程": "镜头流程时间段必须覆盖完整视频时长",
"最终提示词限制": "最终提示词下所有字段都不能写入视频时长、秒数、视频比例、清晰度、分辨率、帧率、推荐像素、竖屏、横屏等视频规格参数,这些规格只能写在画面属性/动态时间规划/输出规格限制。",
@@ -379,42 +753,119 @@ def _is_empty_schema_value(value: Any) -> bool:
return _clean_schema_text(value) in EMPTY_VALUE_TEXTS
def _normalize_schema_object(section_key: str, value: Any) -> dict[str, Any]:
default_value = CLIENT_SCHEMA_V1.get(section_key)
def _schema_default_for_normalize(schema_config_snapshot: Any | None = None) -> dict[str, Any]:
schema = build_client_schema_from_config(schema_config_snapshot)
schema.setdefault("schema_version", PromptSchemaVersionEnum.CLIENT_V1.value)
schema.setdefault("schema_usage", VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value)
return schema
def _object_field_whitelists(schema_config_snapshot: Any | None = None) -> dict[str, set[str]]:
default_schema = _schema_default_for_normalize(schema_config_snapshot)
whitelists: dict[str, set[str]] = {
key: set(value.keys())
for key, value in default_schema.items()
if isinstance(value, dict)
}
whitelists.setdefault("画面属性", set()).update({"视频时长", "视频比例", "清晰度", "帧率", "推荐分辨率"})
whitelists["输出规格限制"] = {"支持时长", "支持比例", "支持分辨率", "当前推荐分辨率"}
return whitelists
def _normalize_schema_object(section_key: str, value: Any, schema_config_snapshot: Any | None = None) -> dict[str, Any]:
default_schema = _schema_default_for_normalize(schema_config_snapshot)
default_value = default_schema.get(section_key)
if not isinstance(default_value, dict):
default_value = {}
source = value if isinstance(value, dict) else {}
merged = copy.deepcopy(default_value)
allowed_keys = OBJECT_FIELD_WHITELISTS.get(section_key, set(default_value.keys()))
allowed_keys = _object_field_whitelists(schema_config_snapshot).get(section_key, set(default_value.keys()))
for field_key in allowed_keys:
if field_key in source:
merged[field_key] = fill_none_with_wu(source.get(field_key))
return merged
def normalize_top_level_schema_fields(result: dict[str, Any]) -> dict[str, Any]:
"""按客户端视频提词 schema 白名单清洗顶层和普通对象字段。
AI 偶尔会把解释性文本作为 JSON key 输出。普通对象中的非预设字段直接过滤,
动作流程/镜头流程这类列表字段在后续 flow normalize 中单独处理。
"""
def normalize_top_level_schema_fields(result: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
"""按客户端视频提词 schema 白名单清洗顶层和普通对象字段。"""
source = result if isinstance(result, dict) else {}
default_schema = _schema_default_for_normalize(schema_config_snapshot)
whitelists = _object_field_whitelists(schema_config_snapshot)
top_level_schema_keys = tuple(default_schema.keys()) + ("动态时间规划", "输出规格限制")
normalized: dict[str, Any] = {}
for key in TOP_LEVEL_SCHEMA_KEYS:
if key in OBJECT_FIELD_WHITELISTS:
normalized[key] = _normalize_schema_object(key, source.get(key))
for key in top_level_schema_keys:
if key in whitelists:
normalized[key] = _normalize_schema_object(key, source.get(key), schema_config_snapshot)
elif key in source:
normalized[key] = fill_none_with_wu(source.get(key))
elif key in CLIENT_SCHEMA_V1:
normalized[key] = copy.deepcopy(CLIENT_SCHEMA_V1[key])
for key, default_value in CLIENT_SCHEMA_V1.items():
elif key in default_schema:
normalized[key] = copy.deepcopy(default_schema[key])
for key, default_value in default_schema.items():
if key not in normalized:
normalized[key] = copy.deepcopy(default_value)
return normalized
def ensure_top_keys(result: dict[str, Any]) -> dict[str, Any]:
return normalize_top_level_schema_fields(result)
def ensure_top_keys(result: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
return normalize_top_level_schema_fields(result, schema_config_snapshot)
def _flow_section_config(schema_config_snapshot: Any | None, flow_key: str) -> dict[str, Any]:
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config_snapshot))
for section in data.get("sections", []):
if isinstance(section, dict) and section.get("key") == flow_key and section.get("type") == "flow" and _normalize_bool(section.get("enabled"), True):
return section
default_key = "镜头内容" if flow_key == "镜头流程" else "动作内容"
aliases = CAMERA_FLOW_CONTENT_KEYS if flow_key == "镜头流程" else ACTION_FLOW_CONTENT_KEYS
return {
"key": flow_key,
"type": "flow",
"enabled": True,
"editable": True,
"content_key": default_key,
"content_aliases": list(aliases),
"max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
"item_fields": [{"key": default_key, "enabled": True, "editable": True, "max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, "value": ""}],
}
def _flow_content_key(schema_config_snapshot: Any | None, flow_key: str) -> str:
section = _flow_section_config(schema_config_snapshot, flow_key)
return str(section.get("content_key") or ("镜头内容" if flow_key == "镜头流程" else "动作内容"))
def _flow_content_aliases(schema_config_snapshot: Any | None, flow_key: str) -> tuple[str, ...]:
section = _flow_section_config(schema_config_snapshot, flow_key)
aliases = section.get("content_aliases") if isinstance(section.get("content_aliases"), list) else []
values = [str(item) for item in aliases if str(item).strip()]
content_key = _flow_content_key(schema_config_snapshot, flow_key)
if content_key not in values:
values.insert(0, content_key)
return tuple(values)
def _flow_item_field_rules(schema_config_snapshot: Any | None, flow_key: str) -> list[dict[str, Any]]:
section = _flow_section_config(schema_config_snapshot, flow_key)
fields = section.get("item_fields") if isinstance(section.get("item_fields"), list) else []
result: list[dict[str, Any]] = []
for field in fields:
if not isinstance(field, dict) or not _normalize_bool(field.get("enabled"), True):
continue
key = str(field.get("key") or "").strip()
if not key or key == "时间段":
continue
result.append(
{
"key": key,
"editable": _normalize_bool(field.get("editable"), True),
"max_length": _normalize_int(field.get("max_length"), VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, min_value=1, max_value=20000),
"value": field.get("value", ""),
}
)
content_key = _flow_content_key(schema_config_snapshot, flow_key)
if not any(item["key"] == content_key for item in result):
result.insert(0, {"key": content_key, "editable": True, "max_length": VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, "value": ""})
return result
def _pick_flow_content(item: dict[str, Any], content_keys: tuple[str, ...]) -> str:
@@ -465,16 +916,24 @@ def _normalize_flow_item(
plan_item: dict[str, str],
content_key: str,
content_keys: tuple[str, ...],
item_fields: list[dict[str, Any]] | None = None,
) -> dict[str, str]:
item = raw_item if isinstance(raw_item, dict) else {}
allowed_keys = {"时间段", content_key}
field_rules = item_fields or [{"key": content_key, "value": ""}]
allowed_keys = {"时间段", *(str(field.get("key")) for field in field_rules if field.get("key"))}
base_content = _pick_flow_content(item, content_keys)
extra_texts = _collect_extra_flow_texts(item, content_keys=content_keys, allowed_keys=allowed_keys)
fallback = plan_item.get("说明") or ""
return {
"时间段": plan_item.get("时间段") or _clean_schema_text(item.get("时间段")) or "",
content_key: _merge_flow_content(base_content, extra_texts, fallback),
}
result: dict[str, str] = {"时间段": plan_item.get("时间段") or _clean_schema_text(item.get("时间段")) or ""}
for field in field_rules:
field_key = str(field.get("key") or "").strip()
if not field_key:
continue
if field_key == content_key:
result[field_key] = _merge_flow_content(base_content, extra_texts, fallback)
else:
result[field_key] = _clean_schema_text(item.get(field_key)) or _clean_schema_text(field.get("value")) or ""
return result
def _normalize_time_plan(plan: list[dict[str, str]], value: Any) -> list[dict[str, str]]:
@@ -492,10 +951,24 @@ def _normalize_time_plan(plan: list[dict[str, str]], value: Any) -> list[dict[st
return normalized
def ensure_flow_matches_time_plan(result: dict[str, Any], duration: int) -> dict[str, Any]:
plan = build_time_plan(duration)
result["动作流程"] = _align_flow_time_ranges(result.get("动作流程"), plan, "动作内容")
result["镜头流程"] = _align_flow_time_ranges(result.get("镜头流程"), plan, "镜头内容")
def ensure_flow_matches_time_plan(result: dict[str, Any], duration: int, schema_config_snapshot: Any | None = None) -> dict[str, Any]:
plan = build_time_plan(duration, schema_config_snapshot)
action_key = _flow_content_key(schema_config_snapshot, "动作流程")
camera_key = _flow_content_key(schema_config_snapshot, "镜头流程")
result["动作流程"] = _align_flow_time_ranges(
result.get("动作流程"),
plan,
action_key,
content_keys=_flow_content_aliases(schema_config_snapshot, "动作流程"),
item_fields=_flow_item_field_rules(schema_config_snapshot, "动作流程"),
)
result["镜头流程"] = _align_flow_time_ranges(
result.get("镜头流程"),
plan,
camera_key,
content_keys=_flow_content_aliases(schema_config_snapshot, "镜头流程"),
item_fields=_flow_item_field_rules(schema_config_snapshot, "镜头流程"),
)
result["动态时间规划"] = _normalize_time_plan(plan, result.get("动态时间规划"))
return result
@@ -591,9 +1064,16 @@ def clean_final_prompt_specs(schema: dict[str, Any], video_config: dict[str, Any
return schema
def _align_flow_time_ranges(flow: Any, plan: list[dict[str, str]], default_content_key: str) -> list[dict[str, str]]:
def _align_flow_time_ranges(
flow: Any,
plan: list[dict[str, str]],
default_content_key: str,
*,
content_keys: tuple[str, ...] | None = None,
item_fields: list[dict[str, Any]] | None = None,
) -> list[dict[str, str]]:
source = flow if isinstance(flow, list) else []
content_keys = ACTION_FLOW_CONTENT_KEYS if default_content_key == "动作内容" else CAMERA_FLOW_CONTENT_KEYS
aliases = content_keys or (ACTION_FLOW_CONTENT_KEYS if default_content_key == "动作内容" else CAMERA_FLOW_CONTENT_KEYS)
aligned: list[dict[str, str]] = []
for index, plan_item in enumerate(plan):
raw_item = source[index] if index < len(source) else {}
@@ -602,43 +1082,110 @@ def _align_flow_time_ranges(flow: Any, plan: list[dict[str, str]], default_conte
raw_item,
plan_item=plan_item,
content_key=default_content_key,
content_keys=content_keys,
content_keys=aliases,
item_fields=item_fields,
)
)
return aligned
def _merge_editable_dict_fields(base: dict[str, Any], patch: dict[str, Any], allowed_keys: set[str]) -> None:
def _config_editable_field_rules(schema_config_snapshot: Any | None = None) -> dict[tuple[str, str], dict[str, Any]]:
data = normalize_video_prompt_schema_config(_extract_config_data(schema_config_snapshot))
rules: dict[tuple[str, str], dict[str, Any]] = {}
for section in data.get("sections", []):
if not isinstance(section, dict) or not _normalize_bool(section.get("enabled"), True):
continue
if str(section.get("type") or "object") != "object":
continue
section_key = str(section.get("key") or "").strip()
for child in section.get("children") or []:
if not isinstance(child, dict) or not _normalize_bool(child.get("enabled"), True):
continue
field_key = str(child.get("key") or "").strip()
if not section_key or not field_key:
continue
rules[(section_key, field_key)] = {
"editable": _normalize_bool(child.get("editable"), True),
"max_length": _normalize_int(child.get("max_length"), VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN, min_value=1, max_value=20000),
}
return rules
def _validate_editable_value_length(path: str, value: Any, max_length: int) -> None:
if isinstance(value, list):
for index, item in enumerate(value):
_validate_editable_value_length(f"{path}[{index}]", item, max_length)
return
if isinstance(value, dict):
for key, item in value.items():
_validate_editable_value_length(f"{path}.{key}", item, max_length)
return
text = str(value or "")
if len(text) > max_length:
raise ValueError(f"字段【{path}】长度不能超过 {max_length} 个字符")
def _is_editable_config_field(section_key: str, field_key: str, schema_config_snapshot: Any | None = None) -> tuple[bool, int]:
# 视频规格、动态规划、输出限制、合规和质量控制继续按原逻辑锁定。
if section_key in {"合规控制", "质量控制", "输出规格限制"}:
return False, VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN
if section_key == "画面属性" and field_key in {"视频时长", "视频比例", "清晰度", "帧率", "推荐分辨率"}:
return False, VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN
rules = _config_editable_field_rules(schema_config_snapshot)
rule = rules.get((section_key, field_key))
if rule is None:
return False, VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN
return bool(rule.get("editable", True)), int(rule.get("max_length") or VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN)
def _merge_editable_dict_fields(
section_key: str,
base: dict[str, Any],
patch: dict[str, Any],
allowed_keys: set[str],
schema_config_snapshot: Any | None = None,
) -> None:
for key in allowed_keys:
if key in patch:
if key not in patch:
continue
editable, max_length = _is_editable_config_field(section_key, key, schema_config_snapshot)
if not editable:
continue
_validate_editable_value_length(f"{section_key}.{key}", patch.get(key), max_length)
base[key] = fill_none_with_wu(patch.get(key))
def _merge_flow_patch(base_flow: Any, patch_flow: Any) -> list[dict[str, Any]]:
def _merge_flow_patch(base_flow: Any, patch_flow: Any, flow_key: str, schema_config_snapshot: Any | None = None) -> list[dict[str, Any]]:
base = [dict(item) for item in base_flow] if isinstance(base_flow, list) else []
patch = patch_flow if isinstance(patch_flow, list) else []
result: list[dict[str, Any]] = []
field_rules = {item["key"]: item for item in _flow_item_field_rules(schema_config_snapshot, flow_key)}
for index, base_item in enumerate(base):
merged = dict(base_item)
patch_item = patch[index] if index < len(patch) and isinstance(patch[index], dict) else {}
original_time_range = merged.get("时间段")
for key, value in patch_item.items():
if key == "时间段":
if key == "时间段" or key not in field_rules:
continue
rule = field_rules[key]
if not _normalize_bool(rule.get("editable"), True):
continue
max_length = _normalize_int(rule.get("max_length"), VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN, min_value=1, max_value=20000)
_validate_editable_value_length(f"{flow_key}[{index}].{key}", value, max_length)
merged[key] = fill_none_with_wu(value)
merged["时间段"] = original_time_range
result.append(merged)
return result
def apply_locked_video_schema_fields(schema: dict[str, Any], video_config: dict[str, Any]) -> dict[str, Any]:
def apply_locked_video_schema_fields(schema: dict[str, Any], video_config: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
duration = int(video_config["duration"])
video_ratio = str(video_config["aspect_ratio"])
resolution = str(video_config["resolution"])
frame_rate = str(video_config.get("frame_rate") or DEFAULT_FRAME_RATE)
recommended_resolution = get_recommended_resolution(video_ratio, resolution)
dynamic_schema = build_dynamic_schema(video_config)
plan = build_time_plan(duration)
dynamic_schema = build_dynamic_schema(video_config, schema_config_snapshot)
plan = build_time_plan(duration, schema_config_snapshot)
schema["schema_version"] = PromptSchemaVersionEnum.CLIENT_V1.value
schema["schema_usage"] = VideoPromptSchemaUsageEnum.CLIENT_DISPLAY.value
@@ -660,25 +1207,37 @@ def apply_locked_video_schema_fields(schema: dict[str, Any], video_config: dict[
schema["动态时间规划"] = plan
schema["输出规格限制"] = dynamic_schema.get("输出规格限制", {})
schema["动作流程"] = _align_flow_time_ranges(schema.get("动作流程"), plan, "动作内容")
schema["镜头流程"] = _align_flow_time_ranges(schema.get("镜头流程"), plan, "镜头内容")
schema["动作流程"] = _align_flow_time_ranges(
schema.get("动作流程"),
plan,
_flow_content_key(schema_config_snapshot, "动作流程"),
content_keys=_flow_content_aliases(schema_config_snapshot, "动作流程"),
item_fields=_flow_item_field_rules(schema_config_snapshot, "动作流程"),
)
schema["镜头流程"] = _align_flow_time_ranges(
schema.get("镜头流程"),
plan,
_flow_content_key(schema_config_snapshot, "镜头流程"),
content_keys=_flow_content_aliases(schema_config_snapshot, "镜头流程"),
item_fields=_flow_item_field_rules(schema_config_snapshot, "镜头流程"),
)
# 合规和质量控制不能被前端降低;AI 返回缺失时使用服务端默认结构补齐。
default_schema = copy.deepcopy(CLIENT_SCHEMA_V1)
if not isinstance(schema.get("合规控制"), dict):
schema["合规控制"] = default_schema["合规控制"]
if not isinstance(schema.get("质量控制"), dict):
schema["质量控制"] = default_schema["质量控制"]
default_schema = build_client_schema_from_config(schema_config_snapshot)
if not isinstance(schema.get("合规控制"), dict) and isinstance(default_schema.get("合规控制"), dict):
schema["合规控制"] = copy.deepcopy(default_schema["合规控制"])
if not isinstance(schema.get("质量控制"), dict) and isinstance(default_schema.get("质量控制"), dict):
schema["质量控制"] = copy.deepcopy(default_schema["质量控制"])
return clean_final_prompt_specs(schema, video_config)
def normalize_video_prompt_schema_from_ai(result: dict[str, Any], video_config: dict[str, Any]) -> dict[str, Any]:
def normalize_video_prompt_schema_from_ai(result: dict[str, Any], video_config: dict[str, Any], schema_config_snapshot: Any | None = None) -> dict[str, Any]:
duration = int(video_config["duration"])
normalized = ensure_top_keys(fill_none_with_wu(result if isinstance(result, dict) else {}))
normalized = ensure_flow_matches_time_plan(normalized, duration)
normalized = ensure_top_keys(fill_none_with_wu(result if isinstance(result, dict) else {}), schema_config_snapshot)
normalized = ensure_flow_matches_time_plan(normalized, duration, schema_config_snapshot)
normalized = ensure_negative_prompt(normalized)
return apply_locked_video_schema_fields(normalized, video_config)
return apply_locked_video_schema_fields(normalized, video_config, schema_config_snapshot)
def patch_video_prompt_schema_from_client(
@@ -686,38 +1245,50 @@ def patch_video_prompt_schema_from_client(
server_schema: dict[str, Any],
client_schema: dict[str, Any],
video_config: dict[str, Any],
schema_config_snapshot: Any | None = None,
) -> dict[str, Any]:
"""以前端 JSON 作为 patch,回填到服务端已有 schema。
禁止整包覆盖:数组长度、时间段、视频规格、输出规格、质量控制、合规控制、schema 协议字段均以服务端为准。
仅对 schema_config_snapshot 中 enabled=true 且 editable=true 的用户可编辑字段做长度校验,不自动裁剪。
"""
base = ensure_top_keys(fill_none_with_wu(copy.deepcopy(server_schema if isinstance(server_schema, dict) else {})))
base = ensure_top_keys(fill_none_with_wu(copy.deepcopy(server_schema if isinstance(server_schema, dict) else {})), schema_config_snapshot)
patch = client_schema if isinstance(client_schema, dict) else {}
whitelists = _object_field_whitelists(schema_config_snapshot)
for key in ("基础分类", "素材理解", "业务属性", "字幕与口播", "音频与节奏"):
if isinstance(base.get(key), dict) and isinstance(patch.get(key), dict):
base[key].update(fill_none_with_wu(patch[key]))
for section_key, allowed_keys in whitelists.items():
if section_key in {"画面属性", "输出规格限制", "合规控制", "质量控制"}:
continue
if isinstance(base.get(section_key), dict) and isinstance(patch.get(section_key), dict):
_merge_editable_dict_fields(section_key, base[section_key], patch[section_key], allowed_keys, schema_config_snapshot)
if isinstance(base.get("画面属性"), dict) and isinstance(patch.get("画面属性"), dict):
_merge_editable_dict_fields(
"画面属性",
base["画面属性"],
patch["画面属性"],
{"主体描述", "主体数量", "主体位置", "主体占比", "场景描述", "构图方式", "画面风格", "光影色彩"},
schema_config_snapshot,
)
if isinstance(patch.get("动作流程"), list):
base["动作流程"] = _merge_flow_patch(base.get("动作流程"), patch.get("动作流程"))
base["动作流程"] = _merge_flow_patch(base.get("动作流程"), patch.get("动作流程"), "动作流程", schema_config_snapshot)
if isinstance(patch.get("镜头流程"), list):
base["镜头流程"] = _merge_flow_patch(base.get("镜头流程"), patch.get("镜头流程"))
base["镜头流程"] = _merge_flow_patch(base.get("镜头流程"), patch.get("镜头流程"), "镜头流程", schema_config_snapshot)
# 动态时间规划保持服务端数组长度和时间段,只允许保留原值;不接受客户端 patch。
if isinstance(base.get("最终提示词"), dict) and isinstance(patch.get("最终提示词"), dict):
for key in VIDEO_SPEC_PROMPT_KEYS:
if key in patch["最终提示词"]:
if key not in patch["最终提示词"]:
continue
editable, max_length = _is_editable_config_field("最终提示词", key, schema_config_snapshot)
if not editable:
continue
_validate_editable_value_length(f"最终提示词.{key}", patch["最终提示词"].get(key), max_length)
base["最终提示词"][key] = fill_none_with_wu(patch["最终提示词"].get(key))
return apply_locked_video_schema_fields(base, video_config)
return apply_locked_video_schema_fields(base, video_config, schema_config_snapshot)
def _mock_result(video_config: dict[str, Any], target_platform: str) -> dict[str, Any]:
@@ -758,13 +1329,14 @@ async def optimize_hot_opening_video_prompt(
generated_image_url: str,
video_config: dict[str, Any],
target_platform: str = "抖音",
schema_config_snapshot: Any | None = None,
) -> tuple[dict[str, Any], str, dict[str, Any]]:
duration = int(video_config["duration"])
references = [
{"type": "video", "url": _build_file_url_or_data_uri(material_video_url)},
{"type": "image", "url": _build_file_url_or_data_uri(generated_image_url)},
]
client_schema = build_dynamic_schema(video_config)
client_schema = build_dynamic_schema(video_config, schema_config_snapshot)
reference_video_fps = int(video_config.get("reference_video_fps") or DEFAULT_REFERENCE_VIDEO_FPS)
# if settings.LLM_MOCK:
@@ -774,7 +1346,7 @@ async def optimize_hot_opening_video_prompt(
config = await _select_model_config(db)
if not config:
result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config)
result = normalize_video_prompt_schema_from_ai(_mock_result(video_config, target_platform), video_config, schema_config_snapshot)
return result, build_final_video_prompt(result), {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0}
user_text = build_user_text(
@@ -785,6 +1357,7 @@ async def optimize_hot_opening_video_prompt(
references=references,
video_config=video_config,
client_schema=client_schema,
schema_config_snapshot=schema_config_snapshot,
)
user_message, log_user_message = build_user_message(user_text, references, reference_video_fps)
request_data = {
@@ -826,7 +1399,7 @@ async def optimize_hot_opening_video_prompt(
await db.flush()
result = parse_model_json(content)
result = normalize_video_prompt_schema_from_ai(result, video_config)
result = normalize_video_prompt_schema_from_ai(result, video_config, schema_config_snapshot)
return result, build_final_video_prompt(result), token_usage
def _build_file_url_or_data_uri(file_url: str) -> str:
@@ -291,11 +291,16 @@ async def update_module_video_prompt_schema(
if not isinstance(video_config, dict) or not video_config.get("duration") or not video_config.get("aspect_ratio") or not video_config.get("resolution"):
raise HTTPException(status_code=400, detail="缺少第4步视频参数快照,不能安全修改视频 schema")
schema_config_snapshot = output_data.get("schema_config_snapshot")
try:
patched_schema = patch_video_prompt_schema_from_client(
server_schema=server_schema,
client_schema=req.prompt_schema,
video_config=video_config,
schema_config_snapshot=schema_config_snapshot,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
final_prompt = build_final_video_prompt(patched_schema)
output_data["prompt_schema"] = patched_schema
@@ -87,6 +87,7 @@ from app.services.module_generation_step_update_service import (
update_module_video_prompt_schema,
)
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.video_prompt_schema_config_service import get_runtime_schema_snapshot
from app.utils.id_gen import generate_id
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.enums.shot_replicate import ShotSegmentReplicateStatusEnum, ShotSplitStatusEnum
@@ -425,6 +426,9 @@ async def project_to_detail_out(db: AsyncSession, project: ModuleGenerationProje
prompt_schema=video_prompt_output.get("prompt_schema"),
final_prompt=video_prompt_output.get("final_prompt"),
prompt_params=video_prompt_output.get("params_used_for_prompt") or video_prompt_input.get("video_config"),
schema_config_snapshot=video_prompt_output.get("schema_config_snapshot"),
schema_config_source=video_prompt_output.get("schema_config_source"),
schema_config_version=video_prompt_output.get("schema_config_version"),
engine_id=video_snapshot.get("id") or video_generate_input.get("engine_id"),
engine_name=video_snapshot.get("name") or video_generate_input.get("engine_name"),
params=video_generate_input.get("params") or video_generate_input,
@@ -1070,6 +1074,8 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
prompt_type=ModulePromptTypeEnum.VIDEO_PROMPT.value,
request=request_log,
)
schema_config_snapshot = await get_runtime_schema_snapshot(db)
request_log["schema_config_source"] = schema_config_snapshot.get("source")
prompt_schema, final_prompt, token_usage = await optimize_shot_replicate_video_prompt(
db,
user_id=project.user_id,
@@ -1080,6 +1086,7 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
generated_image_url=generated_image_url,
video_config=video_config,
target_platform=target_platform,
schema_config_snapshot=schema_config_snapshot,
)
billing = await charge_module_prompt_usage(
db,
@@ -1106,6 +1113,9 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
"final_prompt": final_prompt,
"params_used_for_prompt": video_config,
"target_platform": target_platform,
"schema_config_snapshot": schema_config_snapshot,
"schema_config_source": schema_config_snapshot.get("source"),
"schema_config_version": schema_config_snapshot.get("version"),
},
usage=usage,
),
@@ -1121,7 +1131,7 @@ async def run_video_prompt_optimize(db: AsyncSession, *, project_id: str, step_i
module=project.module,
prompt_type=ModulePromptTypeEnum.VIDEO_PROMPT.value,
request=request_log,
response={"prompt_schema": prompt_schema, "final_prompt": final_prompt},
response={"prompt_schema": prompt_schema, "final_prompt": final_prompt, "schema_config_source": schema_config_snapshot.get("source")},
token_usage=usage,
)
await log_module_event(db, project=project, step=step, event_type=ModuleEventTypeEnum.VIDEO_PROMPT_SUCCESS.value, message="视频 AI 提词生成成功")
@@ -0,0 +1,251 @@
from __future__ import annotations
import copy
import json
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.common import (
VIDEO_SCHEMA_CONFIG_DATABASE_SOURCE,
VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE,
VIDEO_SCHEMA_CONFIG_DESCRIPTION,
VIDEO_SCHEMA_CONFIG_KEY,
VIDEO_SCHEMA_CONFIG_VERSION,
VIDEO_SCHEMA_CONFIG_KEY_MAX_LEN,
VIDEO_SCHEMA_CONFIG_LABEL_MAX_LEN,
VIDEO_SCHEMA_CONFIG_DESC_MAX_LEN,
VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN,
VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN,
VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION,
VIDEO_SCHEMA_MAX_SECTION_COUNT,
VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE,
VIDEO_SCHEMA_MAX_TIME_RULE_COUNT,
VIDEO_SCHEMA_TIME_DESC_MAX_LEN,
VIDEO_SCHEMA_TIME_STAGE_MAX_LEN,
)
from app.models.system_config import SystemConfig
from app.services.hot_opening_video_prompt_service import (
build_dynamic_schema,
build_schema_config_snapshot,
default_video_prompt_schema_config,
normalize_video_prompt_schema_config,
)
from app.utils.id_gen import generate_id
def _json_dumps(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def _json_loads(value: str | None) -> dict[str, Any] | None:
if not value or not str(value).strip():
return None
try:
data = json.loads(value)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"视频提词 Schema 配置不是合法 JSON: {exc}") from exc
if not isinstance(data, dict):
raise HTTPException(status_code=400, detail="视频提词 Schema 配置必须是 JSON 对象")
return data
async def _get_record(db: AsyncSession) -> SystemConfig | None:
result = await db.execute(select(SystemConfig).where(SystemConfig.key == VIDEO_SCHEMA_CONFIG_KEY).limit(1))
return result.scalar_one_or_none()
def _validate_text_length(path: str, value: Any, max_length: int) -> None:
text = str(value or "")
if len(text) > max_length:
raise HTTPException(status_code=400, detail=f"字段【{path}】长度不能超过 {max_length} 个字符")
def validate_video_prompt_schema_config(data: dict[str, Any]) -> dict[str, Any]:
normalized = normalize_video_prompt_schema_config(data)
sections = normalized.get("sections") if isinstance(normalized.get("sections"), list) else []
if len(sections) > VIDEO_SCHEMA_MAX_SECTION_COUNT:
raise HTTPException(status_code=400, detail=f"最多允许配置 {VIDEO_SCHEMA_MAX_SECTION_COUNT} 个一级分组")
seen_sections: set[str] = set()
for section in sections:
key = str(section.get("key") or "")
if not key:
raise HTTPException(status_code=400, detail="一级分组 key 不能为空")
if key in seen_sections:
raise HTTPException(status_code=400, detail=f"一级分组 key 重复: {key}")
seen_sections.add(key)
_validate_text_length(f"{key}.key", key, VIDEO_SCHEMA_CONFIG_KEY_MAX_LEN)
_validate_text_length(f"{key}.label", section.get("label"), VIDEO_SCHEMA_CONFIG_LABEL_MAX_LEN)
_validate_text_length(f"{key}.description", section.get("description"), VIDEO_SCHEMA_CONFIG_DESC_MAX_LEN)
section_type = str(section.get("type") or "object")
if section_type == "object":
children = section.get("children") if isinstance(section.get("children"), list) else []
if len(children) > VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION:
raise HTTPException(status_code=400, detail=f"分组【{key}】最多允许 {VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION} 个字段")
seen_children: set[str] = set()
for child in children:
child_key = str(child.get("key") or "")
if not child_key:
raise HTTPException(status_code=400, detail=f"分组【{key}】存在空字段 key")
if child_key in seen_children:
raise HTTPException(status_code=400, detail=f"分组【{key}】字段 key 重复: {child_key}")
seen_children.add(child_key)
_validate_text_length(f"{key}.{child_key}.key", child_key, VIDEO_SCHEMA_CONFIG_KEY_MAX_LEN)
_validate_text_length(f"{key}.{child_key}.label", child.get("label"), VIDEO_SCHEMA_CONFIG_LABEL_MAX_LEN)
if child.get("editable"):
max_length = int(child.get("max_length") or VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN)
_validate_text_length(f"{key}.{child_key}.value", child.get("value"), max_length)
elif section_type == "flow":
item_fields = section.get("item_fields") if isinstance(section.get("item_fields"), list) else []
if len(item_fields) > VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION:
raise HTTPException(status_code=400, detail=f"流程【{key}】最多允许 {VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION} 个对象字段")
seen_flow_fields: set[str] = set()
for field in item_fields:
field_key = str(field.get("key") or "")
if not field_key:
raise HTTPException(status_code=400, detail=f"流程【{key}】存在空字段 key")
if field_key == "时间段":
raise HTTPException(status_code=400, detail=f"流程【{key}】字段 key 不能使用锁定字段 时间段")
if field_key in seen_flow_fields:
raise HTTPException(status_code=400, detail=f"流程【{key}】字段 key 重复: {field_key}")
seen_flow_fields.add(field_key)
_validate_text_length(f"{key}.{field_key}.key", field_key, VIDEO_SCHEMA_CONFIG_KEY_MAX_LEN)
_validate_text_length(f"{key}.{field_key}.label", field.get("label"), VIDEO_SCHEMA_CONFIG_LABEL_MAX_LEN)
if field.get("editable"):
max_length = int(field.get("max_length") or VIDEO_SCHEMA_FLOW_CONTENT_MAX_LEN)
_validate_text_length(f"{key}.{field_key}.value", field.get("value"), max_length)
else:
if section.get("editable"):
max_length = int(section.get("max_length") or VIDEO_SCHEMA_EDITABLE_TEXT_MAX_LEN)
_validate_text_length(f"{key}.value", section.get("value"), max_length)
rules = normalized.get("time_plan_rules") if isinstance(normalized.get("time_plan_rules"), list) else []
if len(rules) > VIDEO_SCHEMA_MAX_TIME_RULE_COUNT:
raise HTTPException(status_code=400, detail=f"最多允许配置 {VIDEO_SCHEMA_MAX_TIME_RULE_COUNT} 条秒数切片规则")
for index, rule in enumerate(rules):
min_duration = int(rule.get("min_duration") or 0)
max_duration = int(rule.get("max_duration") or 0)
if min_duration <= 0 or max_duration <= 0 or min_duration > max_duration:
raise HTTPException(status_code=400, detail=f"{index + 1} 条秒数切片规则区间不合法")
segments = rule.get("segments") if isinstance(rule.get("segments"), list) else []
ratios = rule.get("ratios") if isinstance(rule.get("ratios"), list) else []
if not segments:
raise HTTPException(status_code=400, detail=f"{index + 1} 条秒数切片规则必须至少包含一个片段")
if len(segments) > VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE:
raise HTTPException(status_code=400, detail=f"{index + 1} 条秒数切片规则最多允许 {VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE} 个片段")
if len(ratios) != len(segments):
raise HTTPException(status_code=400, detail=f"{index + 1} 条秒数切片规则 ratios 数量必须和 segments 数量一致")
for segment_index, segment in enumerate(segments):
_validate_text_length(f"time_plan_rules[{index}].segments[{segment_index}].stage", segment.get("stage"), VIDEO_SCHEMA_TIME_STAGE_MAX_LEN)
_validate_text_length(f"time_plan_rules[{index}].segments[{segment_index}].description", segment.get("description"), VIDEO_SCHEMA_TIME_DESC_MAX_LEN)
return normalized
async def get_video_prompt_schema_config(db: AsyncSession) -> dict[str, Any]:
default_data = default_video_prompt_schema_config()
record = await _get_record(db)
if not record:
return {
"id": None,
"key": VIDEO_SCHEMA_CONFIG_KEY,
"description": VIDEO_SCHEMA_CONFIG_DESCRIPTION,
"is_enabled": False,
"using_default": True,
"data": copy.deepcopy(default_data),
"default_data": default_data,
"created_at": None,
"updated_at": None,
}
data = _json_loads(record.value)
if not data:
data = copy.deepcopy(default_data)
using_default = True
else:
data = normalize_video_prompt_schema_config(data)
using_default = False
return {
"id": record.id,
"key": record.key,
"description": record.description,
"is_enabled": bool(data.get("enabled", True)) and not using_default,
"using_default": using_default,
"data": data,
"default_data": default_data,
"created_at": record.created_at,
"updated_at": record.updated_at,
}
async def save_video_prompt_schema_config(db: AsyncSession, *, data: dict[str, Any], is_enabled: bool = True) -> dict[str, Any]:
normalized = validate_video_prompt_schema_config(data)
normalized["enabled"] = bool(is_enabled)
record = await _get_record(db)
if not record:
record = SystemConfig(
id=generate_id(),
key=VIDEO_SCHEMA_CONFIG_KEY,
value=_json_dumps(normalized),
description=VIDEO_SCHEMA_CONFIG_DESCRIPTION,
)
db.add(record)
else:
record.value = _json_dumps(normalized)
record.description = VIDEO_SCHEMA_CONFIG_DESCRIPTION
await db.flush()
return await get_video_prompt_schema_config(db)
async def reset_video_prompt_schema_config(db: AsyncSession) -> dict[str, Any]:
return await save_video_prompt_schema_config(db, data=default_video_prompt_schema_config(), is_enabled=True)
async def import_video_prompt_schema_config(db: AsyncSession, *, data: dict[str, Any], is_enabled: bool = True) -> dict[str, Any]:
return await save_video_prompt_schema_config(db, data=data, is_enabled=is_enabled)
async def export_video_prompt_schema_config(db: AsyncSession) -> dict[str, Any]:
current = await get_video_prompt_schema_config(db)
return {
"version": VIDEO_SCHEMA_CONFIG_VERSION,
"key": VIDEO_SCHEMA_CONFIG_KEY,
"is_enabled": current["is_enabled"],
"data": current["data"],
}
async def get_runtime_schema_snapshot(db: AsyncSession) -> dict[str, Any]:
record = await _get_record(db)
if not record:
return build_schema_config_snapshot(default_video_prompt_schema_config(), source=VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE)
data = _json_loads(record.value)
if not data:
return build_schema_config_snapshot(default_video_prompt_schema_config(), source=VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE)
normalized = normalize_video_prompt_schema_config(data)
if not normalized.get("enabled", True):
return build_schema_config_snapshot(default_video_prompt_schema_config(), source=VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE)
return build_schema_config_snapshot(normalized, source=VIDEO_SCHEMA_CONFIG_DATABASE_SOURCE)
def fallback_runtime_schema_snapshot(snapshot: dict[str, Any] | None = None) -> dict[str, Any]:
if isinstance(snapshot, dict) and isinstance(snapshot.get("data"), dict):
return snapshot
return build_schema_config_snapshot(default_video_prompt_schema_config(), source=VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE)
def preview_runtime_schema(*, data: dict[str, Any], is_enabled: bool, video_config: dict[str, Any]) -> dict[str, Any]:
normalized = validate_video_prompt_schema_config(data)
normalized["enabled"] = bool(is_enabled)
snapshot = build_schema_config_snapshot(
normalized if normalized.get("enabled", True) else default_video_prompt_schema_config(),
source=VIDEO_SCHEMA_CONFIG_DATABASE_SOURCE if normalized.get("enabled", True) else VIDEO_SCHEMA_CONFIG_DEFAULT_SOURCE,
)
runtime_schema = build_dynamic_schema(video_config, snapshot)
return {
"schema_config_snapshot": snapshot,
"runtime_schema": runtime_schema,
"time_plan": runtime_schema.get("动态时间规划") or [],
}