merge main

This commit is contained in:
sjy
2026-07-17 18:11:02 +08:00
136 changed files with 12822 additions and 2768 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-tEZsB6Nw.js"></script> <script type="module" crossorigin src="/assets/index-CEW5ggCs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css"> <link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head> </head>
<body> <body>
+2
View File
@@ -32,6 +32,7 @@ import AdminShotReplications from './pages/AdminShotReplications';
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail'; import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail'; import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig'; import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
import AdminVideoUpscale from './pages/AdminVideoUpscale';
import AdminContactRequests from './pages/AdminContactRequests'; import AdminContactRequests from './pages/AdminContactRequests';
import AdminHomeMaterials from './pages/AdminHomeMaterials'; import AdminHomeMaterials from './pages/AdminHomeMaterials';
import AdminPreTestTemplates from './pages/AdminPreTestTemplates'; import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
@@ -98,6 +99,7 @@ const App = () => {
<Route path="payment-stats" element={<AdminPaymentStats />} /> <Route path="payment-stats" element={<AdminPaymentStats />} />
<Route path="settings" element={<AdminSettings />} /> <Route path="settings" element={<AdminSettings />} />
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} /> <Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
<Route path="video-upscale" element={<AdminVideoUpscale />} />
<Route path="notifications" element={<AdminNotificationManager />} /> <Route path="notifications" element={<AdminNotificationManager />} />
<Route path="oauthapp-list" element={<AdminOauthAppList />} /> <Route path="oauthapp-list" element={<AdminOauthAppList />} />
<Route path="operation-logs" element={<AdminOperationLogs />} /> <Route path="operation-logs" element={<AdminOperationLogs />} />
+33
View File
@@ -17,6 +17,7 @@ import type {
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams, AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut, PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene, AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload,
} from '../types'; } from '../types';
import type { import type {
@@ -41,6 +42,18 @@ import type {
HomeMaterialWatermarkQueryParams, HomeMaterialWatermarkQueryParams,
} from '../types'; } from '../types';
// ── Video Upscale ────────────────────────────────────────
export async function getVideoUpscaleConfig(): Promise<VideoUpscaleConfigOut> {
return api.get<VideoUpscaleConfigOut>('/admin/video-upscale/config');
}
export async function saveVideoUpscaleConfig(payload: VideoUpscaleConfigSavePayload): Promise<VideoUpscaleConfigOut> {
return api.put<VideoUpscaleConfigOut>('/admin/video-upscale/config', payload);
}
// ── Auth ────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> { export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
@@ -224,6 +237,10 @@ export async function updateSystemConfig(id: string, value: string): Promise<voi
await api.put(`/admin/system-configs/${id}`, { value }); await api.put(`/admin/system-configs/${id}`, { value });
} }
export async function createSystemConfig(key: string, value: string, description?: string): Promise<SystemConfig> {
return api.post('/admin/system-configs', { key, value, description });
}
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> { export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
return api.get('/admin/resource-capacity/global'); return api.get('/admin/resource-capacity/global');
} }
@@ -276,6 +293,22 @@ export async function uploadLogo(file: File): Promise<{ url: string }> {
return { url: res.url }; return { url: res.url };
} }
export async function uploadLoginVideo(file: File): Promise<{ url: string }> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/admin/upload-login-video`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.detail || '上传失败');
}
return res.json();
}
function setMaybe(params: URLSearchParams, key: string, value: unknown): void { function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value)); if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
} }
@@ -0,0 +1,66 @@
import React from 'react';
import { Empty, Spin, Tag, Typography } from 'antd';
import { PlayCircleFilled } from '@ant-design/icons';
import type { GenerationAITaskOut } from '../../types';
interface Props {
task: GenerationAITaskOut;
resolveUrl: (url?: string | null) => string;
onPreview: (url: string, type: 'image' | 'video', title: string) => void;
}
const spanByCount = (count: number, index: number): number => {
if (count <= 1) return 6;
if (count === 2 || count === 4) return 3;
if (count === 3) return index < 2 ? 3 : 6;
return index < 3 ? 2 : 3;
};
const LABELS: Record<string, string> = {
pending: '待处理', queued: '已入队', preparing: '准备中', generating: '生成中',
creating_provider_task: '创建任务中', waiting_remote: '等待生成', polling: '轮询中',
result_ready: '结果就绪', download_queued: '等待下载', downloading: '下载中',
retry_waiting: '等待重试', completed: '已完成', failed: '生成失败',
download_failed: '下载失败', deleted: '已删除',
};
const GenerationTaskResourceGrid: React.FC<Props> = ({ task, resolveUrl, onPreview }) => {
const count = Math.max(1, Math.min(5, Number(task.generationCount || task.childItems?.length || 1)));
const sortedChildren = [...(task.childItems || [])].sort((a, b) => Number(a.generationIndex || 0) - Number(b.generationIndex || 0));
const items: GenerationAITaskOut[] = sortedChildren.length
? sortedChildren
: (count > 1
? Array.from({ length: count }, (_, index) => ({ ...task, id: `${task.id}-${index + 1}`, generationIndex: index + 1, childItems: [] }))
: [task]);
return (
<div style={{ width: '100%', height: 430, display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0,1fr))', gridAutoRows: 'minmax(0,1fr)', gap: items.length > 1 ? 8 : 0 }}>
{items.map((item, index) => {
const status = item.displayStatus || item.pipelineStage || item.status || 'pending';
const isVideo = item.genType === 'video';
const resultUrl = resolveUrl(isVideo ? item.videoUrl : item.imageUrl);
const coverUrl = resolveUrl(item.videoCoverUrl);
const active = ['pending', 'queued', 'preparing', 'generating', 'creating_provider_task', 'waiting_remote', 'polling', 'result_ready', 'download_queued', 'downloading', 'retry_waiting'].includes(status);
return (
<div key={item.id} style={{ gridColumn: `span ${spanByCount(items.length, index)}`, minWidth: 0, minHeight: 0, border: '1px solid #edf0f5', borderRadius: 10, overflow: 'hidden', position: 'relative', background: '#f8f9fc' }}>
{resultUrl && status !== 'deleted' ? (
<button type="button" onClick={() => onPreview(resultUrl, isVideo ? 'video' : 'image', `生成结果 ${item.generationIndex || index + 1}`)} style={{ width: '100%', height: '100%', padding: 0, border: 0, background: 'transparent', cursor: 'pointer', position: 'relative' }}>
{isVideo ? (coverUrl ? <img src={coverUrl} alt="视频封面" style={{ width: '100%', height: '100%', objectFit: 'contain' }} /> : <video src={resultUrl} muted preload="metadata" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />) : <img src={resultUrl} alt="生成图片" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />}
{isVideo ? <PlayCircleFilled style={{ position: 'absolute', left: '50%', top: '50%', transform: 'translate(-50%,-50%)', color: '#fff', fontSize: 38, filter: 'drop-shadow(0 3px 8px rgba(0,0,0,.35))' }} /> : null}
</button>
) : (
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 9, padding: 12, textAlign: 'center' }}>
{active ? <Spin size="small" /> : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={null} />}
<Tag color={status === 'deleted' ? 'default' : (status === 'download_failed' || status === 'failed' ? 'error' : 'processing')}>{LABELS[status] || status}</Tag>
{item.errorMessage && !active ? <Typography.Text type="danger" style={{ fontSize: 11 }}>{item.errorMessage}</Typography.Text> : null}
</div>
)}
{items.length > 1 ? <span style={{ position: 'absolute', top: 6, left: 6, padding: '1px 7px', borderRadius: 10, color: '#fff', background: 'rgba(17,24,39,.58)', fontSize: 11 }}>#{item.generationIndex || index + 1}</span> : null}
</div>
);
})}
</div>
);
};
export default GenerationTaskResourceGrid;
@@ -32,6 +32,7 @@ import dayjs from 'dayjs';
import { getAdminGenerationAiTasks } from '../api'; import { getAdminGenerationAiTasks } from '../api';
import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types'; import type { GenerationAIMediaReference, GenerationAITaskOut } from '../types';
import { formatDate } from '../utils/formatDate'; import { formatDate } from '../utils/formatDate';
import GenerationTaskResourceGrid from '../components/generation/GenerationTaskResourceGrid';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
@@ -71,6 +72,8 @@ const STATUS_MAP: Record<string, { color: string; text: string; icon: React.Reac
generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> }, generating: { color: 'warning', text: '生成中', icon: <LoadingOutlined spin /> },
completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> }, completed: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> }, failed: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
download_failed: { color: 'error', text: '下载失败', icon: <CloseCircleOutlined /> },
deleted: { color: 'default', text: '已删除', icon: <CloseCircleOutlined /> },
}; };
const PIPELINE_STAGE_MAP: Record<string, string> = { const PIPELINE_STAGE_MAP: Record<string, string> = {
@@ -425,6 +428,25 @@ const AdminGenerationAiRecords: React.FC = () => {
return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>; return <Tag color={cfg.color} icon={cfg.icon}>{cfg.text}</Tag>;
}, },
}, },
{
title: '生成数量', key: 'generationCount', width: 150,
render: (_: any, r: GenerationAITaskOut) => {
const count = Math.max(1, Number(r.generationCount || 1));
if (count === 1) return <Tag>1</Tag>;
const children = r.childItems || [];
const completed = children.filter((item) => (item.displayStatus || item.status) === 'completed').length;
const failed = children.filter((item) => ['failed', 'download_failed'].includes(item.displayStatus || item.status)).length;
const deleted = children.filter((item) => (item.displayStatus || item.status) === 'deleted').length;
return (
<Space size={4} wrap>
<Tag color="purple">{count}</Tag>
<Typography.Text style={{ fontSize: 11, color: '#64748b' }}>
{completed}{failed ? ` / ${failed}失败` : ''}{deleted ? ` / ${deleted}删除` : ''}
</Typography.Text>
</Space>
);
},
},
{ {
title: '引擎', key: 'engine', width: 160, title: '引擎', key: 'engine', width: 160,
render: (_: any, r: GenerationAITaskOut) => { render: (_: any, r: GenerationAITaskOut) => {
@@ -1045,6 +1067,7 @@ const AdminGenerationAiRecords: React.FC = () => {
<InfoItem label="用户名称" value={preview.userName || '未知用户'} /> <InfoItem label="用户名称" value={preview.userName || '未知用户'} />
<InfoItem label="用户ID" value={preview.userId || '-'} /> <InfoItem label="用户ID" value={preview.userId || '-'} />
<InfoItem label="任务ID" value={preview.id} /> <InfoItem label="任务ID" value={preview.id} />
<InfoItem label="生成数量" value={`${preview.generationCount || 1}`} />
</div> </div>
<div> <div>
@@ -1122,38 +1145,16 @@ const AdminGenerationAiRecords: React.FC = () => {
</div> </div>
) : null} ) : null}
{preview.status === 'completed' ? ( <div>
<div> <Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block', marginBottom: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}> {preview.generationCount || 1}
<Typography.Text style={{ fontSize: 12, color: '#94a3b8', display: 'block' }}> </Typography.Text>
{preview.genType === 'video' ? '生成视频' : '生成图片'} <GenerationTaskResourceGrid
</Typography.Text> task={preview}
{preview.genType === 'video' && preview.videoUrl ? ( resolveUrl={apiUrl}
<Button onPreview={handlePreviewResource}
size="small" />
type="link" </div>
icon={<PlayCircleOutlined />}
onClick={() => handlePreviewResource(preview.videoUrl!, 'video', '生成视频')}
style={{ padding: 0 }}
>
</Button>
) : null}
{preview.genType === 'image' && preview.imageUrl ? (
<Button
size="small"
type="link"
icon={<FileImageOutlined />}
onClick={() => handlePreviewResource(preview.imageUrl!, 'image', '生成图片')}
style={{ padding: 0 }}
>
</Button>
) : null}
</div>
{preview.genType === 'video' ? renderResultVideo() : renderResultImage()}
</div>
) : null}
{preview.status === 'failed' && preview.errorMessage ? ( {preview.status === 'failed' && preview.errorMessage ? (
<div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}> <div style={{ padding: 12, borderRadius: 10, background: 'rgba(239,68,68,0.04)', border: '1px solid rgba(239,68,68,0.15)' }}>
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd'; import { Button, Card, DatePicker, Input, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; import { EyeOutlined, PlayCircleOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getAdminHotOpeningTasks } from '../api'; import { getAdminHotOpeningTasks } from '../api';
import type { HotOpeningTaskListItemOut } from '../types'; import type { HotOpeningTaskListItemOut } from '../types';
@@ -111,16 +111,15 @@ const AdminHotOpeningReplications: React.FC = () => {
}; };
return ( return (
<div style={{ padding: 24 }}> <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}> <Space>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}> <PlayCircleOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<div> <Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title> <Tag color="purple">{total} </Tag>
<Typography.Text type="secondary"></Typography.Text> </Space>
</div>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button> <Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space> </div>
<Space wrap> <Space wrap>
<Select <Select
@@ -224,9 +223,7 @@ const AdminHotOpeningReplications: React.FC = () => {
}, },
]} ]}
/> />
</Space>
</Card> </Card>
</div>
); );
}; };
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Button, Card, Checkbox, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography, Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd'; } from 'antd';
import { import {
PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined, PictureOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
@@ -21,6 +21,11 @@ interface ImageEngine {
generateUrl: string; generateUrl: string;
isActive: boolean; isActive: boolean;
priority: number; priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
multiImageMaxImages: number;
maxReferenceImageCount: number;
outputFormat: '' | 'png' | 'jpeg';
} }
function parseJsonArray(val: unknown): any[] { function parseJsonArray(val: unknown): any[] {
@@ -80,6 +85,7 @@ const AdminImageEngines: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null }); const [modal, setModal] = useState<{ open: boolean; engine: ImageEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm(); const [form] = Form.useForm();
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
@@ -126,6 +132,11 @@ const AdminImageEngines: React.FC = () => {
generate_url: values.generateUrl || '', generate_url: values.generateUrl || '',
is_active: values.isActive ?? true, is_active: values.isActive ?? true,
priority: values.priority ?? 0, priority: values.priority ?? 0,
multi_generation_enabled: values.multiGenerationEnabled ?? false,
max_generation_count: values.maxGenerationCount ?? 1,
multi_image_max_images: values.multiImageMaxImages ?? 15,
max_reference_image_count: values.maxReferenceImageCount ?? 14,
output_format: values.outputFormat ?? '',
}; };
if (modal.engine) { if (modal.engine) {
await saveImageEngine({ id: modal.engine.id, ...payload }); await saveImageEngine({ id: modal.engine.id, ...payload });
@@ -168,6 +179,8 @@ const AdminImageEngines: React.FC = () => {
form.resetFields(); form.resetFields();
form.setFieldsValue({ form.setFieldsValue({
isActive: true, priority: 0, isActive: true, priority: 0,
multiGenerationEnabled: false, maxGenerationCount: 1, multiImageMaxImages: 15,
maxReferenceImageCount: 14, outputFormat: '',
supportedModels: ['doubao-seedream-5-0-260128'], supportedModels: ['doubao-seedream-5-0-260128'],
defaultSize: '2K', defaultSize: '2K',
maxImageCount: 0, maxImageCount: 0,
@@ -232,6 +245,18 @@ const AdminImageEngines: React.FC = () => {
title: '最大图片', dataIndex: 'maxImageCount', width: 100, title: '最大图片', dataIndex: 'maxImageCount', width: 100,
render: (v: number) => <Tag color="purple">{v} </Tag>, render: (v: number) => <Tag color="purple">{v} </Tag>,
}, },
{
title: '多份生成', dataIndex: 'multiGenerationEnabled', width: 100,
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? '开启' : '关闭'}</Tag>,
},
{
title: '数量上限', dataIndex: 'maxGenerationCount', width: 100,
render: (v: number, r: ImageEngine) => (
<Tag color={r.multiGenerationEnabled && Number(v || 1) > 1 ? 'magenta' : 'default'}>
{r.multiGenerationEnabled ? (v || 1) : 1}
</Tag>
),
},
{ {
title: '状态', dataIndex: 'isActive', width: 80, title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>, render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
@@ -350,6 +375,33 @@ const AdminImageEngines: React.FC = () => {
<Form.Item name="generateUrl" label="生成接口地址"> <Form.Item name="generateUrl" label="生成接口地址">
<Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" /> <Input placeholder="https://ark.cn-beijing.volces.com/api/v3/images/generations" size="large" />
</Form.Item> </Form.Item>
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 12 }}>
<Typography.Text strong></Typography.Text>
<Typography.Paragraph style={{ margin: '6px 0 0', color: '#64748b', fontSize: 12 }}>
2-5 API
</Typography.Paragraph>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 16 }}>
<Form.Item name="multiGenerationEnabled" label="允许客户端多份生成" valuePropName="checked">
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item name="maxGenerationCount" label="客户端最大生成数量" rules={[{ required: true }]}>
<InputNumber min={1} max={5} precision={0} size="large" style={{ width: '100%' }} disabled={!multiGenerationEnabled} />
</Form.Item>
<Form.Item name="multiImageMaxImages" label="组图输入输出总上限" rules={[{ required: true }]}>
<InputNumber min={1} max={15} precision={0} size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="maxReferenceImageCount" label="最大参考图数量" rules={[{ required: true }]}>
<InputNumber min={0} max={14} precision={0} size="large" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="outputFormat" label="供应商输出格式">
<Select size="large" options={[
{ value: '', label: '不传(兼容不支持 output_format 的模型)' },
{ value: 'png', label: 'PNG' },
{ value: 'jpeg', label: 'JPEG' },
]} />
</Form.Item>
</div>
<div style={{ display: 'flex', gap: 16 }}> <div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="priority" label="优先级"> <Form.Item name="priority" label="优先级">
<Select size="large" options={[ <Select size="large" options={[
+223 -71
View File
@@ -1,17 +1,19 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Typography, Upload, Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Tabs, Typography, Upload,
} from 'antd'; } from 'antd';
import { import {
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
createSystemConfig,
getGlobalResourceCapacity, getGlobalResourceCapacity,
getSystemConfigs, getSystemConfigs,
saveGlobalResourceCapacity, saveGlobalResourceCapacity,
updateSystemConfig, updateSystemConfig,
uploadLogo, uploadLogo,
uploadPdf, uploadPdf,
uploadLoginVideo,
} from '../api'; } from '../api';
import type { ResourceCapacityUnit, SystemConfig } from '../types'; import type { ResourceCapacityUnit, SystemConfig } from '../types';
@@ -39,6 +41,10 @@ const AdminSettings: React.FC = () => {
getSystemConfigs(), getSystemConfigs(),
getGlobalResourceCapacity(), getGlobalResourceCapacity(),
]); ]);
// 确保 llm_media_as_base64 配置存在
if (!data.some(c => c.key === 'llm_media_as_base64')) {
data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' });
}
setConfigs(data); setConfigs(data);
const formValues: Record<string, any> = {}; const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; }); data.forEach(c => { formValues[c.key] = c.value; });
@@ -118,6 +124,55 @@ const AdminSettings: React.FC = () => {
return false; return false;
}; };
const handleLoginVideoUpload = async (file: File) => {
setUploading('login_bg_video');
try {
const res = await uploadLoginVideo(file);
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: res.url } : c));
form.setFieldsValue({ login_bg_video: res.url });
const config = configs.find(c => c.key === 'login_bg_video');
if (config) {
await updateSystemConfig(config.id, res.url);
}
message.success('登录背景视频上传成功并已保存');
} catch (e: any) {
message.error(e?.message || '上传失败');
} finally {
setUploading('');
}
return false;
};
const handleRemoveLoginVideo = async () => {
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: '' } : c));
form.setFieldsValue({ login_bg_video: '' });
const config = configs.find(c => c.key === 'login_bg_video');
if (config) {
await updateSystemConfig(config.id, '');
}
message.success('已移除登录背景视频');
};
const handleToggleBase64 = async (checked: boolean) => {
try {
let config = configs.find(c => c.key === 'llm_media_as_base64');
if (config && config.id && !config.id.startsWith('cfg_')) {
await updateSystemConfig(config.id, checked ? 'true' : 'false');
} else {
const res = await createSystemConfig('llm_media_as_base64', checked ? 'true' : 'false', '文字模型请求时图片/视频使用 base64 编码');
config = res;
}
setConfigs(prev => {
const exists = prev.some(c => c.key === 'llm_media_as_base64');
if (exists) return prev.map(c => c.key === 'llm_media_as_base64' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
return [...prev, config!];
});
message.success(`${checked ? '开启' : '关闭'}文字模型媒体 base64 编码`);
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const groupedConfigs: Record<string, SystemConfig[]> = { const groupedConfigs: Record<string, SystemConfig[]> = {
'站点信息': configs.filter(c => c.key.startsWith('site_')), '站点信息': configs.filter(c => c.key.startsWith('site_')),
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'), '协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
@@ -278,6 +333,170 @@ const AdminSettings: React.FC = () => {
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />; return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
} }
const tabItems = [
{
key: 'basic',
label: '网站基础设置',
children: (
<Form form={form} layout="vertical">
{['站点信息', '协议配置', 'SEO 设置'].map(group => (
<div key={group} style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
{group}
</Typography.Text>
{group === '协议配置' ? (
groupedConfigs[group]?.map(config => (
<PdfUploadField key={config.id} config={config} />
))
) : (
groupedConfigs[group]?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))
)}
</div>
))}
</Form>
),
},
{
key: 'credits',
label: '用户积分配置',
children: (
<Form form={form} layout="vertical">
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
{groupedConfigs['用户积分配置']?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))}
</div>
</Form>
),
},
{
key: 'other',
label: '其他配置',
children: (
<Form form={form} layout="vertical">
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
{groupedConfigs['其他配置']?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))}
</div>
{/* 登录背景视频 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Space>
<VideoCameraOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<Typography.Text strong></Typography.Text>
</Space>
<Space>
{form.getFieldValue('login_bg_video') && (
<Button size="small" danger onClick={handleRemoveLoginVideo}>
</Button>
)}
<Upload
accept="video/*,image/gif,image/webp"
showUploadList={false}
beforeUpload={handleLoginVideoUpload}
>
<Button size="small" type="primary" icon={<UploadOutlined />} loading={uploading === 'login_bg_video'}>
</Button>
</Upload>
</Space>
</div>
{(() => {
const url = form.getFieldValue('login_bg_video');
if (!url) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
使 backimage.png
</Typography.Text>
);
}
const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${url}`;
const isGif = url.toLowerCase().endsWith('.gif');
return isGif ? (
<img src={fullUrl} alt="预览" style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#f0f0f5', objectFit: 'contain' }} />
) : (
<video
src={fullUrl}
controls
muted
loop
playsInline
style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#000' }}
/>
);
})()}
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
MP4WebMMOVGIFWebP 50MB
</Typography.Text>
</div>
</div>
{/* 文字模型媒体编码 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space>
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<div>
<Typography.Text strong>/ base64 </Typography.Text>
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
base64 URL
</div>
</div>
</Space>
<Switch
checked={(configs.find(c => c.key === 'llm_media_as_base64') || {}).value === 'true'}
onChange={handleToggleBase64}
checkedChildren="base64"
unCheckedChildren="链接"
/>
</div>
</div>
</div>
</Form>
),
},
];
return ( return (
<div style={{ maxWidth: 720 }}> <div style={{ maxWidth: 720 }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}> <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
@@ -292,78 +511,11 @@ const AdminSettings: React.FC = () => {
</div> </div>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">SEO配置</Typography.Text> <Typography.Text type="secondary"></Typography.Text>
</div> </div>
</div> </div>
<Form form={form} layout="vertical"> <Tabs items={tabItems} defaultActiveKey="basic" />
{Object.entries(groupedConfigs).map(([group, items]) => (
<div key={group} style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
{group}
</Typography.Text>
{group === '协议配置' ? (
items.map(config => (
<PdfUploadField key={config.id} config={config} />
))
) : (
items.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))
)}
</div>
))}
<div style={{ marginBottom: 4 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, border: '1px solid #f0f0f5', borderRadius: 10, background: '#fafbfc' }}>
<Space align="start" style={{ marginBottom: 16 }}>
<DatabaseOutlined style={{ color: '#6366f1', fontSize: 18, marginTop: 2 }} />
<div>
<Typography.Text strong></Typography.Text>
<div style={{ color: '#64748b', fontSize: 13, marginTop: 4 }}>
</div>
</div>
</Space>
<Form.Item
name="resource_capacity_enabled"
label="启用全局容量管控"
valuePropName="checked"
extra="关闭时全局不限制;若用户设置了个人配置,则仍按用户个人配置优先判断。"
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 16 }}>
<Form.Item
name="resource_capacity_limit_value"
label="容量数值"
extra="最小为1,不能为负数,最多支持3位小数。"
rules={[{ required: true, message: '请输入容量数值' }]}
>
<InputNumber min={1} precision={3} style={{ width: '100%' }} size="large" placeholder="例如 10.500" />
</Form.Item>
<Form.Item
name="resource_capacity_limit_unit"
label="容量单位"
extra="MB / GB / TB 固定枚举"
rules={[{ required: true, message: '请选择容量单位' }]}
>
<Select size="large" options={capacityUnitOptions} />
</Form.Item>
</div>
</div>
</div>
</Form>
</Card> </Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd'; import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; import { CameraOutlined, EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getAdminShotTaskSets } from '../api'; import { getAdminShotTaskSets } from '../api';
import type { ShotTaskSetOut } from '../types'; import type { ShotTaskSetOut } from '../types';
@@ -134,16 +134,15 @@ const AdminShotReplications: React.FC = () => {
}; };
return ( return (
<div style={{ padding: 24 }}> <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}> <Space>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}> <CameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<div> <Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title> <Tag color="purple">{total} </Tag>
<Typography.Text type="secondary">AI </Typography.Text> </Space>
</div>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button> <Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space> </div>
<Space wrap> <Space wrap>
<Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} /> <Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} />
@@ -211,9 +210,7 @@ const AdminShotReplications: React.FC = () => {
}, },
]} ]}
/> />
</Space>
</Card> </Card>
</div>
); );
}; };
+20 -1
View File
@@ -3,7 +3,7 @@ import {
Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography, Button, Card, Checkbox, Form, Input, InputNumber, message, Modal, Popconfirm, Progress, Select, Space, Switch, Table, Tabs, Tag, Typography,
} from 'antd'; } from 'antd';
import { import {
UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined, UserOutlined, WalletOutlined, SearchOutlined, StopOutlined, CheckCircleOutlined, PlusOutlined, MinusOutlined, MenuOutlined, LockOutlined, SettingOutlined, SaveOutlined, DatabaseOutlined, TeamOutlined, PictureOutlined, SecurityScanOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
adjustCredits, adjustCredits,
@@ -411,6 +411,7 @@ const AdminUsers: React.FC = () => {
{r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}></Tag>} {r.isAdmin && <Tag color="orange" style={{ marginLeft: 6, fontSize: 10 }}></Tag>}
</div> </div>
<div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div> <div style={{ color: '#94a3b8', fontSize: 12 }}>{r.email}</div>
<div style={{ color: '#c0c4cc', fontSize: 11, fontFamily: 'monospace' }}>ID: {r.id}</div>
</div> </div>
</Space> </Space>
), ),
@@ -693,6 +694,24 @@ const AdminUsers: React.FC = () => {
{creditModal.user?.credits.toLocaleString()} {creditModal.user?.credits.toLocaleString()}
</span> </span>
</div> </div>
{/* 快捷操作 */}
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}></Typography.Text>
<Space wrap>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 1000, description: '积分赠送' })}>
+1000 /
</Button>
<Button size="small" icon={<PlusOutlined />} style={{ color: '#10b981' }} onClick={() => form.setFieldsValue({ amount: 500, description: '积分赠送' })}>
+500 /
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -500, description: '积分扣除' })}>
-500 /
</Button>
<Button size="small" icon={<MinusOutlined />} style={{ color: '#ef4444' }} onClick={() => form.setFieldsValue({ amount: -1000, description: '积分扣除' })}>
-1000 /
</Button>
</Space>
</div>
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
<Form.Item name="amount" label="积分变动" <Form.Item name="amount" label="积分变动"
rules={[{ required: true, message: '请输入积分数量' }]}> rules={[{ required: true, message: '请输入积分数量' }]}>
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Button, Card, Form, Input, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography, Button, Card, Form, Input, InputNumber, message, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Typography,
} from 'antd'; } from 'antd';
import { import {
PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined, PlayCircleOutlined, PlusOutlined, EditOutlined, DeleteOutlined,
@@ -25,6 +25,8 @@ interface VideoEngine {
supportsUniversalReference: boolean; supportsUniversalReference: boolean;
isActive: boolean; isActive: boolean;
priority: number; priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
} }
function parseJsonArray(val: unknown): any[] { function parseJsonArray(val: unknown): any[] {
@@ -40,6 +42,7 @@ const AdminVideoEngines: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null }); const [modal, setModal] = useState<{ open: boolean; engine: VideoEngine | null }>({ open: false, engine: null });
const [form] = Form.useForm(); const [form] = Form.useForm();
const multiGenerationEnabled = Form.useWatch('multiGenerationEnabled', form) ?? false;
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
@@ -80,6 +83,8 @@ const AdminVideoEngines: React.FC = () => {
supports_universal_reference: values.supportsUniversalReference ?? true, supports_universal_reference: values.supportsUniversalReference ?? true,
is_active: values.isActive ?? true, is_active: values.isActive ?? true,
priority: values.priority ?? 0, priority: values.priority ?? 0,
multi_generation_enabled: values.multiGenerationEnabled ?? false,
max_generation_count: values.maxGenerationCount ?? 1,
}; };
if (modal.engine) { if (modal.engine) {
await saveVideoEngine({ id: modal.engine.id, ...payload }); await saveVideoEngine({ id: modal.engine.id, ...payload });
@@ -115,6 +120,7 @@ const AdminVideoEngines: React.FC = () => {
form.resetFields(); form.resetFields();
form.setFieldsValue({ form.setFieldsValue({
isActive: true, priority: 0, isActive: true, priority: 0,
multiGenerationEnabled: false, maxGenerationCount: 1,
maxDuration: 30, maxDuration: 30,
maxImageCount: 2, maxImageCount: 2,
maxVideoCount: 0, maxVideoCount: 0,
@@ -180,6 +186,18 @@ const AdminVideoEngines: React.FC = () => {
title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100, title: '全能参考', dataIndex: 'supportsUniversalReference', width: 100,
render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>, render: (v: boolean) => <Tag color={v ? 'purple' : 'default'}>{v ? '支持' : '不支持'}</Tag>,
}, },
{
title: '多份生成', dataIndex: 'multiGenerationEnabled', width: 100,
render: (v: boolean) => <Tag color={v ? 'blue' : 'default'}>{v ? '开启' : '关闭'}</Tag>,
},
{
title: '数量上限', dataIndex: 'maxGenerationCount', width: 100,
render: (v: number, r: VideoEngine) => (
<Tag color={r.multiGenerationEnabled && Number(v || 1) > 1 ? 'magenta' : 'default'}>
{r.multiGenerationEnabled ? (v || 1) : 1}
</Tag>
),
},
{ {
title: '状态', dataIndex: 'isActive', width: 80, title: '状态', dataIndex: 'isActive', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>, render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '停用'}</Tag>,
@@ -315,7 +333,19 @@ const AdminVideoEngines: React.FC = () => {
<Switch /> <Switch />
</Form.Item> </Form.Item>
</div> </div>
<div style={{ background: '#f8f9fc', borderRadius: 10, padding: 16, marginBottom: 12 }}>
<Typography.Text strong></Typography.Text>
<Typography.Paragraph style={{ margin: '6px 0 0', color: '#64748b', fontSize: 12 }}>
1
</Typography.Paragraph>
</div>
<div style={{ display: 'flex', gap: 16 }}> <div style={{ display: 'flex', gap: 16 }}>
<Form.Item name="multiGenerationEnabled" label="允许客户端多份生成" valuePropName="checked" style={{ flex: 1 }}>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<Form.Item name="maxGenerationCount" label="客户端最大生成数量" style={{ flex: 1 }} rules={[{ required: true }]}>
<InputNumber min={1} max={5} precision={0} size="large" style={{ width: '100%' }} disabled={!multiGenerationEnabled} />
</Form.Item>
<Form.Item name="priority" label="优先级" style={{ flex: 1 }}> <Form.Item name="priority" label="优先级" style={{ flex: 1 }}>
<Select size="large" options={[ <Select size="large" options={[
{ value: 0, label: '0 (默认)' }, { value: 0, label: '0 (默认)' },
@@ -0,0 +1,262 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
App,
Button,
Card,
Col,
Empty,
Row,
Select,
Space,
Spin,
Switch,
Tag,
Typography,
} from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons';
import { getVideoUpscaleConfig, saveVideoUpscaleConfig } from '../api';
import type {
VideoUpscaleConfigData,
VideoUpscaleProcessorKey,
VideoUpscaleResolutionRule,
} from '../types';
const { Title, Text, Paragraph } = Typography;
const PROCESSORS: Array<{ key: VideoUpscaleProcessorKey; label: string }> = [
{ key: 'local_ffmpeg_crop_v1', label: '本地 FFmpegcrop' },
{ key: 'volc_standard_v1', label: '火山画质增强(标准版)' },
{ key: 'volc_professional_v1', label: '火山画质增强(专业版)' },
{ key: 'volc_large_model_v1', label: '火山画质增强(大模型)' },
];
const RESOLUTION_OPTIONS = ['480p', '720p', '1080p', '2K', '4K'].map((value) => ({
label: value,
value,
}));
const defaultRule = (): VideoUpscaleResolutionRule => ({
targetResolution: '1080p',
providerGenerationResolution: '720p',
processorKey: 'local_ffmpeg_crop_v1',
enabled: true,
});
function normalizeConfig(data: VideoUpscaleConfigData): VideoUpscaleConfigData {
return {
enabled: !!data.enabled,
version: Number(data.version || 1),
deleteSourceAfterSuccess: data.deleteSourceAfterSuccess !== false,
rules: Array.isArray(data.rules) ? data.rules.map((rule) => ({ ...rule })) : [],
};
}
function toSavePayload(data: VideoUpscaleConfigData) {
return {
data: {
enabled: data.enabled,
version: data.version,
delete_source_after_success: data.deleteSourceAfterSuccess,
rules: data.rules.map((rule) => ({
target_resolution: rule.targetResolution,
provider_generation_resolution: rule.providerGenerationResolution,
processor_key: rule.processorKey,
enabled: rule.enabled,
})),
},
};
}
const AdminVideoUpscale: React.FC = () => {
const { message, modal } = App.useApp();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [config, setConfig] = useState<VideoUpscaleConfigData | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await getVideoUpscaleConfig();
setConfig(normalizeConfig(result.data));
} catch (error) {
message.error(error instanceof Error ? error.message : '读取视频超分配置失败');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => {
void load();
}, [load]);
const updateRule = (index: number, patch: Partial<VideoUpscaleResolutionRule>) => {
setConfig((current) => {
if (!current) return current;
return {
...current,
rules: current.rules.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item),
};
});
};
const removeRule = (index: number) => {
setConfig((current) => current ? {
...current,
rules: current.rules.filter((_, itemIndex) => itemIndex !== index),
} : current);
};
const addRule = () => {
setConfig((current) => current ? { ...current, rules: [...current.rules, defaultRule()] } : current);
};
const save = async () => {
if (!config) return;
const targets = config.rules
.filter((item) => item.enabled)
.map((item) => item.targetResolution.trim().toLowerCase());
if (new Set(targets).size !== targets.length) {
message.error('同一个客户目标分辨率只能存在一条启用规则');
return;
}
setSaving(true);
try {
const result = await saveVideoUpscaleConfig(toSavePayload(config));
setConfig(normalizeConfig(result.data));
message.success(`视频超分配置已保存,版本 ${result.data.version}`);
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败');
} finally {
setSaving(false);
}
};
if (loading || !config) {
return (
<div style={{ minHeight: 360, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
);
}
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card>
<Row justify="space-between" align="middle" gutter={[16, 16]}>
<Col>
<Title level={3} style={{ margin: 0 }}></Title>
<Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
</Paragraph>
</Col>
<Col>
<Space>
<Tag> {config.version}</Tag>
<Button icon={<ReloadOutlined />} onClick={() => void load()}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void save()}>
</Button>
</Space>
</Col>
</Row>
<Space direction="vertical" size={14} style={{ marginTop: 20 }}>
<Space>
<Text strong></Text>
<Switch
checked={config.enabled}
onChange={(enabled) => setConfig({ ...config, enabled })}
/>
<Text type="secondary"></Text>
</Space>
<Space>
<Text strong></Text>
<Switch
checked={config.deleteSourceAfterSuccess}
onChange={(deleteSourceAfterSuccess) => setConfig({ ...config, deleteSourceAfterSuccess })}
/>
<Text type="secondary">
</Text>
</Space>
</Space>
</Card>
<Card
title="目标分辨率规则"
extra={<Button icon={<PlusOutlined />} onClick={addRule}></Button>}
>
{config.rules.length === 0 ? (
<Empty description="暂无规则;未匹配规则的视频任务继续走原流程" />
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{config.rules.map((rule, index) => {
const duplicate = rule.enabled && config.rules.filter(
(item) => item.enabled && item.targetResolution.toLowerCase() === rule.targetResolution.toLowerCase(),
).length > 1;
return (
<Card key={`${index}-${rule.targetResolution}`} size="small">
<Row gutter={[12, 12]} align="bottom">
<Col xs={24} md={5}>
<Text type="secondary"></Text>
<Select
value={rule.targetResolution}
options={RESOLUTION_OPTIONS}
style={{ width: '100%', marginTop: 4 }}
onChange={(value) => updateRule(index, { targetResolution: value })}
status={duplicate ? 'error' : undefined}
/>
</Col>
<Col xs={24} md={5}>
<Text type="secondary"></Text>
<Select
value={rule.providerGenerationResolution}
options={RESOLUTION_OPTIONS}
style={{ width: '100%', marginTop: 4 }}
onChange={(value) => updateRule(index, { providerGenerationResolution: value })}
/>
</Col>
<Col xs={24} md={7}>
<Text type="secondary"></Text>
<Select
value={rule.processorKey}
options={PROCESSORS.map((item) => ({ value: item.key, label: item.label }))}
style={{ width: '100%', marginTop: 4 }}
onChange={(value) => updateRule(index, { processorKey: value })}
/>
</Col>
<Col xs={12} md={2}>
<Text type="secondary"></Text>
<div style={{ marginTop: 8 }}>
<Switch checked={rule.enabled} onChange={(enabled) => updateRule(index, { enabled })} />
</div>
</Col>
<Col xs={12} md={2}>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => modal.confirm({
title: '删除这条超分规则?',
onOk: () => removeRule(index),
})}
/>
</Col>
<Col span={24}>
<Text type="secondary">
</Text>
</Col>
</Row>
</Card>
);
})}
</Space>
)}
</Card>
</Space>
);
};
export default AdminVideoUpscale;
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd'; import { Alert, Button, Empty, Image, Space, Typography, message } from 'antd';
import { CopyOutlined, LinkOutlined } from '@ant-design/icons'; import { CopyOutlined, LinkOutlined } from '@ant-design/icons';
import { copyToClipboard } from '../../../utils/clipboard';
const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000'; const RAW_API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, ''); const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
@@ -37,12 +38,8 @@ const MediaPreview: React.FC<MediaPreviewProps> = ({
const copyUrl = async () => { const copyUrl = async () => {
if (!resolvedUrl) return; if (!resolvedUrl) return;
try { const ok = await copyToClipboard(resolvedUrl);
await navigator.clipboard.writeText(resolvedUrl); message.success(ok ? '资源地址已复制' : '复制失败,请手动复制');
message.success('资源地址已复制');
} catch {
message.error('复制失败,请手动复制');
}
}; };
const tools = resolvedUrl ? ( const tools = resolvedUrl ? (
+60
View File
@@ -281,6 +281,10 @@ export interface GenerationAiImageEngine {
supportedSizes: Record<string, Record<string, string>>; supportedSizes: Record<string, Record<string, string>>;
defaultSize: string; defaultSize: string;
priority: number; priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
multiImageMaxImages: number;
maxReferenceImageCount: number;
} }
export interface GenerationAiVideoEngine { export interface GenerationAiVideoEngine {
@@ -299,6 +303,8 @@ export interface GenerationAiVideoEngine {
supportsFirstLastFrame?: boolean; supportsFirstLastFrame?: boolean;
supportsUniversalReference?: boolean; supportsUniversalReference?: boolean;
priority: number; priority: number;
multiGenerationEnabled: boolean;
maxGenerationCount: number;
} }
export interface GenerationAiEnginesResponse { export interface GenerationAiEnginesResponse {
@@ -326,6 +332,10 @@ export interface GenerationAiEngineOption {
supportsFirstLastFrame?: boolean; supportsFirstLastFrame?: boolean;
supportsUniversalReference?: boolean; supportsUniversalReference?: boolean;
priority: number; priority: number;
multiGenerationEnabled?: boolean;
maxGenerationCount?: number;
multiImageMaxImages?: number;
maxReferenceImageCount?: number;
genType: GenerationAiGenType; genType: GenerationAiGenType;
} }
@@ -399,6 +409,10 @@ export interface GenerationAITaskOut {
projectId?: string | null; projectId?: string | null;
genType: GenerationAiGenType | string; genType: GenerationAiGenType | string;
generationMode?: string | null; generationMode?: string | null;
parentTaskId?: string | null;
generationCount: number;
generationIndex?: number | null;
displayStatus?: string | null;
pipelineStage?: string | null; pipelineStage?: string | null;
status: GenerationAITaskStatus; status: GenerationAITaskStatus;
originalPrompt: string; originalPrompt: string;
@@ -428,6 +442,7 @@ export interface GenerationAITaskOut {
errorMessage?: string | null; errorMessage?: string | null;
createdAt?: string | null; createdAt?: string | null;
generatedAt?: string | null; generatedAt?: string | null;
childItems: GenerationAITaskOut[];
} }
export interface GenerationAITaskListOut { export interface GenerationAITaskListOut {
@@ -1293,3 +1308,48 @@ export interface PrivatePortraitSelectableAssetListOut {
page: number; page: number;
pageSize: number; pageSize: number;
} }
// ── Video Upscale ────────────────────────────────────────
export type VideoUpscaleProcessorKey =
| 'local_ffmpeg_crop_v1'
| 'volc_large_model_v1'
| 'volc_standard_v1'
| 'volc_professional_v1';
export interface VideoUpscaleResolutionRule {
targetResolution: '480p' | '720p' | '1080p' | '2K' | '4K';
providerGenerationResolution: '480p' | '720p' | '1080p' | '2K' | '4K';
processorKey: VideoUpscaleProcessorKey;
enabled: boolean;
}
export interface VideoUpscaleConfigData {
enabled: boolean;
version: number;
deleteSourceAfterSuccess: boolean;
rules: VideoUpscaleResolutionRule[];
}
export interface VideoUpscaleConfigOut {
id?: string | null;
key: string;
description?: string | null;
data: VideoUpscaleConfigData;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface VideoUpscaleConfigSavePayload {
data: {
enabled: boolean;
version: number;
delete_source_after_success: boolean;
rules: Array<{
target_resolution: string;
provider_generation_resolution: string;
processor_key: VideoUpscaleProcessorKey;
enabled: boolean;
}>;
};
}
+23
View File
@@ -0,0 +1,23 @@
/** 安全复制文本到剪贴板,兼容非 HTTPS 环境 */
export async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
await navigator.clipboard.writeText(text);
return true;
}
// 降级方案:使用 textarea + execCommand
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
const succeeded = document.execCommand('copy');
document.body.removeChild(textarea);
return succeeded;
} catch {
return false;
}
}
+35 -8
View File
@@ -1,11 +1,38 @@
const CST_OFFSET = 8 * 60; // CST = UTC+8, in minutes
export function formatDate(iso: string | null | undefined): string { export function formatDate(iso: string | null | undefined): string {
if (!iso) return '-'; if (!iso) return '-';
let s = iso.trim(); const s = iso.trim();
if (!s.includes('T')) s = s.replace(' ', 'T'); if (!s) return '-';
// Truncate microseconds: 2026-05-13T15:04:04.313751 → 2026-05-13T15:04:04
const dotIdx = s.indexOf('.'); // Parse the ISO string, handling timezone offset
if (dotIdx > 0) s = s.slice(0, dotIdx); // Match: 2026-05-13T15:04:04.313751+00:00 or 2026-05-13T15:04:04Z or 2026-05-13T15:04:04
// Remove any trailing timezone info (backend now sends naive datetimes) const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$/);
s = s.replace(/[+-]\d{2}:?\d{0,2}$/, '').replace(/Z$/, ''); if (!m) return s.slice(0, 16).replace('T', ' ');
return s.replace('T', ' ').slice(0, 16);
const [, year, month, day, hour, min, sec, tz] = m;
// Build a Date in UTC
const utcMs = Date.UTC(+year, +month - 1, +day, +hour, +min, +sec);
if (tz && tz !== 'Z') {
// Has explicit offset like +00:00 or +08:00 — already accounted for in the matched components
// We parsed HH:MM:SS as-is, which are in the given offset.
// Convert to UTC first by subtracting the offset
const sign = tz[0] === '+' ? 1 : -1;
const [oh, om] = tz.slice(1).split(':');
const offsetMin = sign * (+oh * 60 + +om);
const localMs = utcMs - offsetMin * 60000 + CST_OFFSET * 60000;
const d = new Date(localMs);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
// No tz or Z: if Z it's UTC, if no tz it's naive (assume CST from backend)
const isUTC = tz === 'Z';
const localMs = isUTC ? utcMs + CST_OFFSET * 60000 : utcMs;
const d = new Date(localMs);
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
}
function pad(n: number): string {
return n < 10 ? `0${n}` : String(n);
} }
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"} {"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
+1
View File
@@ -58,6 +58,7 @@ VIDEO_COVER_TIMEOUT_SECONDS=15
VIDEO_COVER_FORMAT=png VIDEO_COVER_FORMAT=png
# VOLC # VOLC
VOLC_API_KEY=AKLTOWMwMjVhNzg0OGE2NDMwZWJkYWIyNzM3ZmMxMjc5NTQ
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ== VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
@@ -0,0 +1,180 @@
"""add video upscale pipeline
Revision ID: 3f47680a71d0
Revises: abae3e1c70f7
Create Date: 2026-07-16 11:08:16.449960
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "3f47680a71d0"
down_revision: Union[str, None] = "abae3e1c70f7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"chat_generation_tasks",
sa.Column("provider_generation_resolution", sa.String(length=16), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column(
"video_upscale_enabled_snapshot",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"chat_generation_tasks",
sa.Column("video_upscale_snapshot_json", sa.Text(), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("provider_generation_resolution", sa.String(length=16), nullable=True),
)
op.add_column(
"generation_records",
sa.Column(
"video_upscale_enabled_snapshot",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"generation_records",
sa.Column("video_upscale_snapshot_json", sa.Text(), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("pipeline_stage", sa.String(length=48), nullable=True),
)
op.create_index(
"ix_generation_records_pipeline_stage",
"generation_records",
["pipeline_stage"],
unique=False,
)
op.create_table(
"video_upscale_tasks",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("chat_generation_task_id", sa.String(length=32), nullable=True),
sa.Column("generation_record_id", sa.String(length=32), nullable=True),
sa.Column("status", sa.String(length=32), server_default="pending", nullable=False),
sa.Column("stage", sa.String(length=48), server_default="upscale_queued", nullable=False),
sa.Column("processor_key", sa.String(length=64), nullable=False),
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("failure_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("manual_retry_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("source_local_path", sa.Text(), nullable=True),
sa.Column("source_file_size_bytes", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("source_width", sa.Integer(), nullable=True),
sa.Column("source_height", sa.Integer(), nullable=True),
sa.Column("source_duration_seconds", sa.Float(), nullable=True),
sa.Column("source_deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_delete_error", sa.Text(), nullable=True),
sa.Column("source_remote_url", sa.Text(), nullable=True),
sa.Column("source_remote_url_signed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_remote_url_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_remote_url_last_probe_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_remote_url_probe_status", sa.String(length=32), nullable=True),
sa.Column("input_source_type", sa.String(length=32), nullable=True),
sa.Column("input_source_fallback_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("target_width", sa.Integer(), nullable=False),
sa.Column("target_height", sa.Integer(), nullable=False),
sa.Column("effective_target_width", sa.Integer(), nullable=True),
sa.Column("effective_target_height", sa.Integer(), nullable=True),
sa.Column("provider_task_id", sa.String(length=160), nullable=True),
sa.Column("provider_request_json", sa.Text(), nullable=True),
sa.Column("provider_response_json", sa.Text(), nullable=True),
sa.Column("provider_output_url", sa.Text(), nullable=True),
sa.Column("provider_output_url_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("provider_submitted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("final_local_path", sa.Text(), nullable=True),
sa.Column("final_resource_url", sa.Text(), nullable=True),
sa.Column("final_file_size_bytes", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("celery_task_id", sa.String(length=160), nullable=True),
sa.Column("lease_token", sa.String(length=64), nullable=True),
sa.Column("lease_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("failed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint(
"(chat_generation_task_id IS NOT NULL AND generation_record_id IS NULL) OR "
"(chat_generation_task_id IS NULL AND generation_record_id IS NOT NULL)",
name="ck_video_upscale_tasks_exactly_one_owner",
),
sa.ForeignKeyConstraint(
["chat_generation_task_id"],
["chat_generation_tasks.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["generation_record_id"],
["generation_records.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"idx_video_upscale_tasks_provider_task_id",
"video_upscale_tasks",
["provider_task_id"],
unique=False,
)
op.create_index(
"idx_video_upscale_tasks_status_lease",
"video_upscale_tasks",
["status", "lease_until"],
unique=False,
)
op.create_index(
"idx_video_upscale_tasks_status_next_retry",
"video_upscale_tasks",
["status", "next_retry_at"],
unique=False,
)
op.create_index(
"uq_video_upscale_tasks_chat_task",
"video_upscale_tasks",
["chat_generation_task_id"],
unique=True,
)
op.create_index(
"uq_video_upscale_tasks_generation_record",
"video_upscale_tasks",
["generation_record_id"],
unique=True,
)
def downgrade() -> None:
op.drop_index("uq_video_upscale_tasks_generation_record", table_name="video_upscale_tasks")
op.drop_index("uq_video_upscale_tasks_chat_task", table_name="video_upscale_tasks")
op.drop_index("idx_video_upscale_tasks_status_next_retry", table_name="video_upscale_tasks")
op.drop_index("idx_video_upscale_tasks_status_lease", table_name="video_upscale_tasks")
op.drop_index("idx_video_upscale_tasks_provider_task_id", table_name="video_upscale_tasks")
op.drop_table("video_upscale_tasks")
op.drop_index("ix_generation_records_pipeline_stage", table_name="generation_records")
op.drop_column("generation_records", "pipeline_stage")
op.drop_column("generation_records", "video_upscale_snapshot_json")
op.drop_column("generation_records", "video_upscale_enabled_snapshot")
op.drop_column("generation_records", "provider_generation_resolution")
op.drop_column("chat_generation_tasks", "video_upscale_snapshot_json")
op.drop_column("chat_generation_tasks", "video_upscale_enabled_snapshot")
op.drop_column("chat_generation_tasks", "provider_generation_resolution")
@@ -0,0 +1,230 @@
"""add client-selectable multi generation and image batch claim
Revision ID: abae3e1c70f7
Revises: 2026070902
Create Date: 2026-07-15 10:49:31.803342
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "abae3e1c70f7"
down_revision: Union[str, None] = "2026070902"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
FK_CHAT_TASK_PARENT = "fk_chat_generation_tasks_parent_task_id"
CK_CHAT_TASK_GENERATION_COUNT = "ck_chat_generation_tasks_generation_count"
CK_CHAT_TASK_GENERATION_INDEX = "ck_chat_generation_tasks_generation_index"
CK_IMAGE_ENGINE_MAX_GENERATION_COUNT = "ck_image_engines_max_generation_count"
CK_IMAGE_ENGINE_MULTI_IMAGE_MAX = "ck_image_engines_multi_image_max_images"
CK_IMAGE_ENGINE_MAX_REFERENCE = "ck_image_engines_max_reference_image_count"
CK_VIDEO_ENGINE_MAX_GENERATION_COUNT = "ck_video_engines_max_generation_count"
def upgrade() -> None:
# ChatGenerationTask:任务级实际生成数量、主子关联和图片批次执行租约。
op.add_column(
"chat_generation_tasks",
sa.Column("parent_task_id", sa.String(length=32), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column("generation_count", sa.Integer(), server_default=sa.text("1"), nullable=False),
)
op.add_column(
"chat_generation_tasks",
sa.Column("generation_index", sa.Integer(), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column("provider_create_claim_token", sa.String(length=64), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column("provider_create_lease_until", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column("provider_create_started_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_check_constraint(
CK_CHAT_TASK_GENERATION_COUNT,
"chat_generation_tasks",
"generation_count BETWEEN 1 AND 5",
)
op.create_check_constraint(
CK_CHAT_TASK_GENERATION_INDEX,
"chat_generation_tasks",
"generation_index IS NULL OR generation_index > 0",
)
op.create_foreign_key(
FK_CHAT_TASK_PARENT,
"chat_generation_tasks",
"chat_generation_tasks",
["parent_task_id"],
["id"],
ondelete="RESTRICT",
)
op.create_index(
"idx_chat_generation_tasks_parent",
"chat_generation_tasks",
["parent_task_id"],
unique=False,
)
op.create_index(
"idx_chat_generation_tasks_user_mode_created",
"chat_generation_tasks",
["user_id", "generation_mode", "created_at"],
unique=False,
)
op.create_index(
"ix_chat_generation_tasks_provider_create_claim_token",
"chat_generation_tasks",
["provider_create_claim_token"],
unique=False,
)
op.create_index(
"ix_chat_generation_tasks_provider_create_lease_until",
"chat_generation_tasks",
["provider_create_lease_until"],
unique=False,
)
op.create_index(
"uq_chat_generation_tasks_parent_index",
"chat_generation_tasks",
["parent_task_id", "generation_index"],
unique=True,
postgresql_where=sa.text(
"parent_task_id IS NOT NULL AND generation_index IS NOT NULL"
),
)
op.create_index(
"uq_chat_generation_tasks_user_chat_idempotency",
"chat_generation_tasks",
["user_id", "idempotency_key"],
unique=True,
postgresql_where=sa.text(
"deleted_at IS NULL "
"AND idempotency_key IS NOT NULL "
"AND generation_mode IN ('chatapi_async', 'chatapi_main')"
),
)
# ImageEngine:管理后台只配置是否允许客户端多份生成和数量上限。
op.add_column(
"image_engines",
sa.Column(
"multi_generation_enabled",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"image_engines",
sa.Column("max_generation_count", sa.Integer(), server_default=sa.text("1"), nullable=False),
)
op.add_column(
"image_engines",
sa.Column("multi_image_max_images", sa.Integer(), server_default=sa.text("15"), nullable=False),
)
op.add_column(
"image_engines",
sa.Column("max_reference_image_count", sa.Integer(), server_default=sa.text("14"), nullable=False),
)
op.add_column(
"image_engines",
sa.Column("output_format", sa.String(length=16), server_default=sa.text("''"), nullable=False),
)
op.create_check_constraint(
CK_IMAGE_ENGINE_MAX_GENERATION_COUNT,
"image_engines",
"max_generation_count BETWEEN 1 AND 5",
)
op.create_check_constraint(
CK_IMAGE_ENGINE_MULTI_IMAGE_MAX,
"image_engines",
"multi_image_max_images BETWEEN 1 AND 15",
)
op.create_check_constraint(
CK_IMAGE_ENGINE_MAX_REFERENCE,
"image_engines",
"max_reference_image_count BETWEEN 0 AND 14",
)
# VideoEngine:管理后台只配置是否允许客户端多份生成和数量上限。
op.add_column(
"video_engines",
sa.Column(
"multi_generation_enabled",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"video_engines",
sa.Column("max_generation_count", sa.Integer(), server_default=sa.text("1"), nullable=False),
)
op.create_check_constraint(
CK_VIDEO_ENGINE_MAX_GENERATION_COUNT,
"video_engines",
"max_generation_count BETWEEN 1 AND 5",
)
def downgrade() -> None:
op.drop_constraint(CK_VIDEO_ENGINE_MAX_GENERATION_COUNT, "video_engines", type_="check")
op.drop_column("video_engines", "max_generation_count")
op.drop_column("video_engines", "multi_generation_enabled")
op.drop_constraint(CK_IMAGE_ENGINE_MAX_REFERENCE, "image_engines", type_="check")
op.drop_constraint(CK_IMAGE_ENGINE_MULTI_IMAGE_MAX, "image_engines", type_="check")
op.drop_constraint(CK_IMAGE_ENGINE_MAX_GENERATION_COUNT, "image_engines", type_="check")
op.drop_column("image_engines", "output_format")
op.drop_column("image_engines", "max_reference_image_count")
op.drop_column("image_engines", "multi_image_max_images")
op.drop_column("image_engines", "max_generation_count")
op.drop_column("image_engines", "multi_generation_enabled")
op.drop_index(
"uq_chat_generation_tasks_user_chat_idempotency",
table_name="chat_generation_tasks",
postgresql_where=sa.text(
"deleted_at IS NULL "
"AND idempotency_key IS NOT NULL "
"AND generation_mode IN ('chatapi_async', 'chatapi_main')"
),
)
op.drop_index(
"uq_chat_generation_tasks_parent_index",
table_name="chat_generation_tasks",
postgresql_where=sa.text(
"parent_task_id IS NOT NULL AND generation_index IS NOT NULL"
),
)
op.drop_index(
"ix_chat_generation_tasks_provider_create_lease_until",
table_name="chat_generation_tasks",
)
op.drop_index(
"ix_chat_generation_tasks_provider_create_claim_token",
table_name="chat_generation_tasks",
)
op.drop_index("idx_chat_generation_tasks_user_mode_created", table_name="chat_generation_tasks")
op.drop_index("idx_chat_generation_tasks_parent", table_name="chat_generation_tasks")
op.drop_constraint(FK_CHAT_TASK_PARENT, "chat_generation_tasks", type_="foreignkey")
op.drop_constraint(CK_CHAT_TASK_GENERATION_INDEX, "chat_generation_tasks", type_="check")
op.drop_constraint(CK_CHAT_TASK_GENERATION_COUNT, "chat_generation_tasks", type_="check")
op.drop_column("chat_generation_tasks", "provider_create_started_at")
op.drop_column("chat_generation_tasks", "provider_create_lease_until")
op.drop_column("chat_generation_tasks", "provider_create_claim_token")
op.drop_column("chat_generation_tasks", "generation_index")
op.drop_column("chat_generation_tasks", "generation_count")
op.drop_column("chat_generation_tasks", "parent_task_id")
+2
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
from app.api.admin.video_upscale import router as video_upscale_router
from app.api.admin.resource_capacity import router as resource_capacity_router from app.api.admin.resource_capacity import router as resource_capacity_router
from app.api.admin.team import router as team_router from app.api.admin.team import router as team_router
from app.api.admin.home_material import router as home_material_router from app.api.admin.home_material import router as home_material_router
@@ -11,6 +12,7 @@ from app.api.admin.upload import router as admin_upload_router
router = APIRouter() router = APIRouter()
router.include_router(video_prompt_schema_config_router) router.include_router(video_prompt_schema_config_router)
router.include_router(video_upscale_router)
router.include_router(resource_capacity_router) router.include_router(resource_capacity_router)
router.include_router(team_router) router.include_router(team_router)
router.include_router(home_material_router) router.include_router(home_material_router)
@@ -0,0 +1,53 @@
from __future__ import annotations
import json
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_upscale import VideoUpscaleConfigOut, VideoUpscaleConfigSaveRequest
from app.services.operation_log import log_operation
from app.services.video_upscale.config_service import get_video_upscale_config, save_video_upscale_config
router = APIRouter(prefix="/admin/video-upscale", tags=["admin-video-upscale"])
@router.get("/config", response_model=VideoUpscaleConfigOut, summary="获取视频超分配置")
async def get_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
_ = admin
return await get_video_upscale_config(db)
@router.put("/config", response_model=VideoUpscaleConfigOut, summary="保存视频超分配置")
async def save_config(
req: VideoUpscaleConfigSaveRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
before = await get_video_upscale_config(db)
result = await save_video_upscale_config(db, req.data)
await log_operation(
db,
admin.id,
admin.username,
"保存视频超分配置",
"PUT",
"/admin/video-upscale/config",
detail=json.dumps(
{
"before_version": before["data"].get("version"),
"after_version": result["data"].get("version"),
"enabled": result["data"].get("enabled"),
"delete_source_after_success": result["data"].get("delete_source_after_success"),
"rule_count": len(result["data"].get("rules") or []),
},
ensure_ascii=False,
),
)
await db.commit()
return result
+121 -11
View File
@@ -24,10 +24,12 @@ from app.models.credit_ratio import CreditRatio
from app.models.operation_log import OperationLog from app.models.operation_log import OperationLog
from app.enums.user import FrontendUserKind, UserType from app.enums.user import FrontendUserKind, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.enums.generation_status import GenerationRecordPipelineStage
from app.schemas.admin import ( from app.schemas.admin import (
CreditAdjustRequest, CreditAdjustRequest,
ModelConfigCreate, ModelConfigCreate,
ModelConfigOut, ModelConfigOut,
SystemConfigCreate,
SystemConfigUpdate, SystemConfigUpdate,
SystemConfigOut, SystemConfigOut,
AdminUserOut, AdminUserOut,
@@ -55,12 +57,12 @@ from app.services.payment import sync_pending_orders, process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
from app.services.generation_billing_service import ( from app.services.generation.billing_service import (
OWNER_GENERATION_RECORD, OWNER_GENERATION_RECORD,
charge_generation_media_by_params, charge_generation_media_by_params,
get_next_credit_attempt_no, get_next_credit_attempt_no,
) )
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once from app.services.generation.refund_service import mark_generation_record_failed_and_refund_once
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS
@@ -1593,6 +1595,34 @@ async def list_system_configs(
return result.scalars().all() return result.scalars().all()
@router.post("/system-configs", response_model=SystemConfigOut)
async def create_system_config(
req: SystemConfigCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
from app.utils.id_gen import generate_id
config = SystemConfig(
id=generate_id(),
key=req.key,
value=str(req.value),
description=req.description or "",
)
db.add(config)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"创建系统配置: {config.key}",
"POST",
"/admin/system-configs",
detail=json.dumps({"key": req.key, "value": req.value}, ensure_ascii=False),
)
await db.commit()
return config
@router.put("/system-configs/{config_id}", response_model=SystemConfigOut) @router.put("/system-configs/{config_id}", response_model=SystemConfigOut)
async def update_system_config( async def update_system_config(
config_id: str, config_id: str,
@@ -1668,21 +1698,21 @@ async def get_stats(
start_date: str = Query(None), start_date: str = Query(None),
end_date: str = Query(None), end_date: str = Query(None),
): ):
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) today_start = datetime.now(CST).replace(hour=0, minute=0, second=0, microsecond=0)
try: try:
if start_date: if start_date:
date_start = datetime.strptime(start_date, "%Y-%m-%d") date_start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST)
else: else:
date_start = today_start date_start = today_start
if end_date: if end_date:
date_end = datetime.strptime(end_date, "%Y-%m-%d") date_end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=CST)
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999) date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
else: else:
date_end = datetime.now() date_end = datetime.now(CST)
except: except:
date_start = today_start date_start = today_start
date_end = datetime.now() date_end = datetime.now(CST)
total_users = (await db.execute( total_users = (await db.execute(
select(func.count(User.id)).where( select(func.count(User.id)).where(
@@ -1920,6 +1950,8 @@ async def admin_list_generation_records(
"aspect_ratio": record.aspect_ratio, "aspect_ratio": record.aspect_ratio,
"resolution": record.resolution, "resolution": record.resolution,
"status": record.status, "status": record.status,
"pipeline_stage": record.pipeline_stage,
"video_upscale_enabled": bool(record.video_upscale_enabled_snapshot),
"video_url": build_resource_signed_url(record.video_url) if record.video_url else '', "video_url": build_resource_signed_url(record.video_url) if record.video_url else '',
"video_cover_url": build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '', "video_cover_url": build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
"references": refs, "references": refs,
@@ -1983,7 +2015,7 @@ async def admin_update_generation_status(
if body.get("image_url"): if body.get("image_url"):
record.image_url = body["image_url"] record.image_url = body["image_url"]
if new_status == "completed": if new_status == "completed":
record.generated_at = datetime.now() record.generated_at = datetime.now(CST)
await db.flush() await db.flush()
await log_operation( await log_operation(
db, db,
@@ -2033,6 +2065,8 @@ async def admin_generate_video(
if record.status not in ("prompt_optimized", "failed"): if record.status not in ("prompt_optimized", "failed"):
raise HTTPException(status_code=400, detail=f"当前状态不允许生成{type_str}") raise HTTPException(status_code=400, detail=f"当前状态不允许生成{type_str}")
if record.pipeline_stage == GenerationRecordPipelineStage.UPSCALE_FAILED.value:
raise HTTPException(status_code=409, detail="该任务为画质增强失败,请使用超分恢复命令处理")
attempt_no = await get_next_credit_attempt_no( attempt_no = await get_next_credit_attempt_no(
db, db,
@@ -2049,6 +2083,21 @@ async def admin_generate_video(
if resolution not in RESOLUTIONS: if resolution not in RESOLUTIONS:
raise HTTPException(status_code=400, detail="不支持的分辨率") raise HTTPException(status_code=400, detail="不支持的分辨率")
from app.services.video_gen import get_active_engine
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
engine = await get_active_engine(db)
try:
supported_provider_resolutions = json.loads(engine.supported_resolutions or "[]")
except (TypeError, json.JSONDecodeError):
supported_provider_resolutions = []
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
db,
target_resolution=resolution,
aspect_ratio=aspect_ratio,
supported_provider_resolutions=supported_provider_resolutions,
)
duration = record.duration or 5 duration = record.duration or 5
media_billing = await charge_generation_media_by_params( media_billing = await charge_generation_media_by_params(
db, db,
@@ -2065,6 +2114,10 @@ async def admin_generate_video(
record.aspect_ratio = aspect_ratio record.aspect_ratio = aspect_ratio
record.resolution = resolution record.resolution = resolution
record.provider_generation_resolution = provider_resolution
record.video_upscale_enabled_snapshot = upscale_enabled
record.video_upscale_snapshot_json = upscale_snapshot_json
record.pipeline_stage = GenerationRecordPipelineStage.CREATING_PROVIDER_TASK.value
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2) record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
record.status = "generating" record.status = "generating"
record.error_message = None record.error_message = None
@@ -2075,8 +2128,7 @@ async def admin_generate_video(
await db.flush() await db.flush()
try: try:
from app.services.video_gen import get_active_engine, submit_video_task from app.services.video_gen import submit_video_task
engine = await get_active_engine(db)
task_id = await submit_video_task( task_id = await submit_video_task(
db, db,
engine, engine,
@@ -2084,9 +2136,11 @@ async def admin_generate_video(
include_media_references=False, include_media_references=False,
) )
record.seedance_task_id = task_id record.seedance_task_id = task_id
record.pipeline_stage = GenerationRecordPipelineStage.WAITING_REMOTE.value
await db.flush() await db.flush()
await task_queue.enqueue(record_id) await task_queue.enqueue(record_id)
except Exception as e: except Exception as e:
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once( await mark_generation_record_failed_and_refund_once(
db, db,
record=record, record=record,
@@ -2119,6 +2173,10 @@ async def admin_generate_video(
record.video_url = None record.video_url = None
record.video_cover_url = None record.video_cover_url = None
record.seedance_task_id = None record.seedance_task_id = None
record.provider_generation_resolution = None
record.video_upscale_enabled_snapshot = False
record.video_upscale_snapshot_json = None
record.pipeline_stage = None
await db.flush() await db.flush()
try: try:
@@ -2281,6 +2339,58 @@ async def upload_logo(
return {"url": url} return {"url": url}
@router.post("/upload-login-video")
async def upload_login_video(
file: UploadFile = File(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""上传登录页背景视频/动图,保存 URL 到 system config login_bg_video。"""
from app.config import settings
if not file.filename:
raise HTTPException(status_code=400, detail="请选择文件")
content = await file.read()
if len(content) > 50 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过50MB")
ext = os.path.splitext(file.filename)[1].lower()
safe_name = f"login_bg_{generate_id()}{ext}"
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, safe_name)
with open(file_path, "wb") as f:
f.write(content)
url = f"/uploads/{safe_name}"
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "login_bg_video").limit(1)
)
config = result.scalar_one_or_none()
if config:
config.value = url
else:
db.add(SystemConfig(
id=generate_id(),
key="login_bg_video",
value=url,
description="登录页背景视频",
))
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"上传登录背景视频: {file.filename}",
"POST",
"/admin/upload-login-video",
detail=json.dumps({"filename": file.filename, "url": url}, ensure_ascii=False),
)
await db.commit()
return {"url": url}
# ── Payment Stats ──────────────────────────────────────── # ── Payment Stats ────────────────────────────────────────
+25 -10
View File
@@ -1,4 +1,6 @@
from datetime import datetime from datetime import datetime, timezone, timedelta
CST = timezone(timedelta(hours=8))
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select from sqlalchemy import select
@@ -15,6 +17,7 @@ from app.models.system_config import SystemConfig
from app.models.user import User from app.models.user import User
from app.schemas.auth import ( from app.schemas.auth import (
ChangePasswordRequest, ChangePasswordRequest,
ChangeUsernameRequest,
LoginRequest, LoginRequest,
RegisterRequest, RegisterRequest,
SetPasswordRequest, SetPasswordRequest,
@@ -116,8 +119,8 @@ async def _handle_daily_login_credits(db: AsyncSession, user: User) -> None:
credits = int(credits_result.scalar_one_or_none() or "0") credits = int(credits_result.scalar_one_or_none() or "0")
if credits <= 0: if credits <= 0:
return return
today = datetime.now().date() today = datetime.now(CST).date()
if user.last_login_at: if user.last_login_at:
last_login_date = user.last_login_at.date() last_login_date = user.last_login_at.date()
if last_login_date >= today: if last_login_date >= today:
@@ -159,7 +162,7 @@ async def login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
) )
await _handle_daily_login_credits(db, user) await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now() user.last_login_at = datetime.now(CST)
await db.flush() await db.flush()
return _token_response(user, req.remember_me) return _token_response(user, req.remember_me)
@@ -190,7 +193,7 @@ async def sms_login(req: SmsLoginRequest, db: AsyncSession = Depends(get_db)):
) )
await _handle_daily_login_credits(db, user) await _handle_daily_login_credits(db, user)
user.last_login_at = datetime.now() user.last_login_at = datetime.now(CST)
await db.flush() await db.flush()
return _token_response(user, req.remember_me) return _token_response(user, req.remember_me)
@@ -222,7 +225,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
username=req.phone, username=req.phone,
phone=req.phone, phone=req.phone,
hashed_password=hash_password(req.password), hashed_password=hash_password(req.password),
password_set_at=datetime.now(), password_set_at=datetime.now(CST),
credits=register_credits, credits=register_credits,
is_admin=False, is_admin=False,
user_type="frontend", user_type="frontend",
@@ -294,7 +297,7 @@ async def set_password(
) )
current_user.hashed_password = hash_password(req.new_password) current_user.hashed_password = hash_password(req.new_password)
current_user.password_set_at = datetime.now() current_user.password_set_at = datetime.now(CST)
await db.flush() await db.flush()
return {"message": "密码设置成功", "must_set_password": False} return {"message": "密码设置成功", "must_set_password": False}
@@ -318,17 +321,28 @@ async def change_password(
) )
current_user.hashed_password = hash_password(req.new_password) current_user.hashed_password = hash_password(req.new_password)
current_user.password_set_at = datetime.now() current_user.password_set_at = datetime.now(CST)
await db.flush() await db.flush()
return {"message": "密码修改成功"} return {"message": "密码修改成功"}
@router.post("/change-username")
async def change_username(
req: ChangeUsernameRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
current_user.username = req.new_username.strip()
await db.flush()
return {"message": "用户名修改成功"}
@router.get("/site-info") @router.get("/site-info")
async def get_site_info(db: AsyncSession = Depends(get_db)): async def get_site_info(db: AsyncSession = Depends(get_db)):
"""Public endpoint returning site name, logo, agreement and copyright info.""" """Public endpoint returning site name, logo, agreement and copyright info."""
result = await db.execute( result = await db.execute(
select(SystemConfig).where(SystemConfig.key.in_([ select(SystemConfig).where(SystemConfig.key.in_([
"site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual" "site_name", "site_logo", "user_agreement_privacy_url", "site_copyright", "operation_manual", "login_bg_video"
])) ]))
) )
configs = result.scalars().all() configs = result.scalars().all()
@@ -351,6 +365,7 @@ async def get_site_info(db: AsyncSession = Depends(get_db)):
"user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")), "user_agreement_privacy_url": to_full_url(info.get("user_agreement_privacy_url")),
"site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"), "site_copyright": info.get("site_copyright", "© 2024 民众智创 版权所有"),
"operation_manual": info.get("operation_manual", ""), "operation_manual": info.get("operation_manual", ""),
"login_bg_video": to_full_url(info.get("login_bg_video")) if info.get("login_bg_video") else "",
} }
@@ -370,7 +385,7 @@ async def admin_login(req: LoginRequest, db: AsyncSession = Depends(get_db)):
detail="该账号不是管理员账号", detail="该账号不是管理员账号",
) )
user.last_login_at = datetime.now() user.last_login_at = datetime.now(CST)
await db.flush() await db.flush()
token = create_access_token(user.id, req.remember_me) token = create_access_token(user.id, req.remember_me)
+109 -61
View File
@@ -1,7 +1,9 @@
import json import json
import logging import logging
import os import os
from datetime import datetime from datetime import datetime, timezone, timedelta
CST = timezone(timedelta(hours=8))
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile, File, status
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@@ -39,7 +41,8 @@ from app.services.resource_capacity_service import assert_user_resource_capacity
from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit
from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log
from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum
from app.services.generation_billing_service import ( from app.enums.generation_status import GenerationRecordPipelineStage
from app.services.generation.billing_service import (
CHARGE_TEXT_PROMPT, CHARGE_TEXT_PROMPT,
OWNER_GENERATION_RECORD, OWNER_GENERATION_RECORD,
build_credit_biz_key, build_credit_biz_key,
@@ -47,7 +50,7 @@ from app.services.generation_billing_service import (
charge_generation_media_for_record, charge_generation_media_for_record,
get_next_credit_attempt_no, get_next_credit_attempt_no,
) )
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once from app.services.generation.refund_service import mark_generation_record_failed_and_refund_once
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot
from app.services.credit_record_meta_service import build_generation_record_prompt_meta from app.services.credit_record_meta_service import build_generation_record_prompt_meta
from app.services.video_cover_service import async_create_video_cover_for_local_video from app.services.video_cover_service import async_create_video_cover_for_local_video
@@ -99,6 +102,8 @@ def _record_to_out(record: GenerationRecord, project_name: str, refs_override: l
image_proportion=record.image_proportion, image_proportion=record.image_proportion,
image_px=record.image_px, image_px=record.image_px,
status=record.status, status=record.status,
pipeline_stage=record.pipeline_stage,
video_upscale_enabled=bool(record.video_upscale_enabled_snapshot),
video_url=build_resource_signed_url(record.video_url) if record.video_url else '', video_url=build_resource_signed_url(record.video_url) if record.video_url else '',
video_cover_url=build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '', video_cover_url=build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
image_url=build_resource_signed_url(record.image_url) if record.image_url else '', image_url=build_resource_signed_url(record.image_url) if record.image_url else '',
@@ -414,6 +419,8 @@ async def generate(
record, project_name = row record, project_name = row
if record.status not in ("prompt_optimized", "failed"): if record.status not in ("prompt_optimized", "failed"):
raise InvalidStatusError("当前状态不允许生成") raise InvalidStatusError("当前状态不允许生成")
if record.pipeline_stage == GenerationRecordPipelineStage.UPSCALE_FAILED.value:
raise InvalidStatusError("该任务生成失败,请联系客服进行修复")
await assert_user_resource_capacity_available(db, current_user.id) await assert_user_resource_capacity_available(db, current_user.id)
@@ -430,6 +437,21 @@ async def generate(
if req.resolution not in RESOLUTIONS: if req.resolution not in RESOLUTIONS:
raise HTTPException(status_code=400, detail="不支持的分辨率") raise HTTPException(status_code=400, detail="不支持的分辨率")
from app.services.video_gen import get_active_engine
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
engine = await get_active_engine(db)
try:
supported_provider_resolutions = json.loads(engine.supported_resolutions or "[]")
except (TypeError, json.JSONDecodeError):
supported_provider_resolutions = []
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
db,
target_resolution=req.resolution,
aspect_ratio=req.aspect_ratio,
supported_provider_resolutions=supported_provider_resolutions,
)
duration = record.duration or 5 duration = record.duration or 5
media_billing = await charge_generation_media_by_params( media_billing = await charge_generation_media_by_params(
db, db,
@@ -446,6 +468,10 @@ async def generate(
record.aspect_ratio = req.aspect_ratio record.aspect_ratio = req.aspect_ratio
record.resolution = req.resolution record.resolution = req.resolution
record.provider_generation_resolution = provider_resolution
record.video_upscale_enabled_snapshot = upscale_enabled
record.video_upscale_snapshot_json = upscale_snapshot_json
record.pipeline_stage = GenerationRecordPipelineStage.CREATING_PROVIDER_TASK.value
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2) record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
record.status = "generating" record.status = "generating"
record.error_message = None record.error_message = None
@@ -456,11 +482,10 @@ async def generate(
await db.flush() await db.flush()
try: try:
from app.services.video_gen import get_active_engine, submit_video_task from app.services.video_gen import submit_video_task
from app.services.error_codes import extract_error_message from app.services.error_codes import extract_error_message
from app.services.video_queue import task_queue from app.services.video_queue import task_queue
engine = await get_active_engine(db)
task_id = await submit_video_task( task_id = await submit_video_task(
db, db,
engine, engine,
@@ -468,9 +493,11 @@ async def generate(
include_media_references=False, include_media_references=False,
) )
record.seedance_task_id = task_id record.seedance_task_id = task_id
record.pipeline_stage = GenerationRecordPipelineStage.WAITING_REMOTE.value
await db.flush() await db.flush()
await task_queue.enqueue(record_id) await task_queue.enqueue(record_id)
except Exception as e: except Exception as e:
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once( await mark_generation_record_failed_and_refund_once(
db, db,
record=record, record=record,
@@ -500,6 +527,10 @@ async def generate(
record.video_url = None record.video_url = None
record.video_cover_url = None record.video_cover_url = None
record.seedance_task_id = None record.seedance_task_id = None
record.provider_generation_resolution = None
record.video_upscale_enabled_snapshot = False
record.video_upscale_snapshot_json = None
record.pipeline_stage = None
await db.flush() await db.flush()
try: try:
@@ -541,6 +572,8 @@ async def retry_generation(
record, project_name = row record, project_name = row
if record.status != "failed": if record.status != "failed":
raise InvalidStatusError("只有失败的记录可以重试") raise InvalidStatusError("只有失败的记录可以重试")
if record.pipeline_stage == GenerationRecordPipelineStage.UPSCALE_FAILED.value:
raise InvalidStatusError("该任务生成失败,请联系客服进行修复")
await assert_user_resource_capacity_available(db, current_user.id) await assert_user_resource_capacity_available(db, current_user.id)
@@ -549,6 +582,27 @@ async def retry_generation(
owner_type=OWNER_GENERATION_RECORD, owner_type=OWNER_GENERATION_RECORD,
owner_id=record.id, owner_id=record.id,
) )
engine = None
if record.gen_type == GenerationType.video:
from app.services.video_gen import get_active_engine
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
engine = await get_active_engine(db)
try:
supported_provider_resolutions = json.loads(engine.supported_resolutions or "[]")
except (TypeError, json.JSONDecodeError):
supported_provider_resolutions = []
provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot(
db,
target_resolution=record.resolution or "",
aspect_ratio=record.aspect_ratio or "",
supported_provider_resolutions=supported_provider_resolutions,
)
record.provider_generation_resolution = provider_resolution
record.video_upscale_enabled_snapshot = upscale_enabled
record.video_upscale_snapshot_json = upscale_snapshot_json
record.pipeline_stage = GenerationRecordPipelineStage.CREATING_PROVIDER_TASK.value
media_billing = await charge_generation_media_for_record( media_billing = await charge_generation_media_for_record(
db, db,
record=record, record=record,
@@ -570,8 +624,8 @@ async def retry_generation(
try: try:
from app.services.video_queue import task_queue from app.services.video_queue import task_queue
if record.gen_type == GenerationType.video: if record.gen_type == GenerationType.video:
from app.services.video_gen import get_active_engine, submit_video_task, extract_error_message from app.services.video_gen import submit_video_task
engine = await get_active_engine(db) assert engine is not None
task_id = await submit_video_task( task_id = await submit_video_task(
db, db,
engine, engine,
@@ -579,10 +633,13 @@ async def retry_generation(
include_media_references=False, include_media_references=False,
) )
record.seedance_task_id = task_id record.seedance_task_id = task_id
record.pipeline_stage = GenerationRecordPipelineStage.WAITING_REMOTE.value
await db.flush() await db.flush()
await task_queue.enqueue(record_id) await task_queue.enqueue(record_id)
except Exception as e: except Exception as e:
from app.services.error_codes import extract_error_message from app.services.error_codes import extract_error_message
if record.gen_type == GenerationType.video:
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once( await mark_generation_record_failed_and_refund_once(
db, db,
record=record, record=record,
@@ -677,6 +734,8 @@ async def get_queue_status(
return { return {
"record_id": record.id, "record_id": record.id,
"status": record.status, "status": record.status,
"pipeline_stage": record.pipeline_stage,
"video_upscale_enabled": bool(record.video_upscale_enabled_snapshot),
"queue_position": queue_position, "queue_position": queue_position,
"estimated_wait_seconds": estimated_wait_seconds, "estimated_wait_seconds": estimated_wait_seconds,
} }
@@ -703,65 +762,54 @@ async def seedance_callback(request: Request, db: AsyncSession = Depends(get_db)
record = result.scalar_one_or_none() record = result.scalar_one_or_none()
if not record: if not record:
return {"message": "record not found"} return {"message": "record not found"}
if record.status == "completed":
return {"message": "already completed"}
if str(record.pipeline_stage or "").startswith("upscale_"):
return {"message": "upscale already started"}
if task_status == "succeeded": if task_status == "succeeded":
remote_url = data.get("content", {}).get("video_url", "") remote_url = str(data.get("content", {}).get("video_url", "") or "").strip()
record.status = "completed" if not remote_url:
storage_path = None record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
file_size_bytes = 0 await mark_generation_record_failed_and_refund_once(
# Download video to local storage db, record=record, error_message="供应商回调成功但未返回视频地址"
if settings.STORAGE_TYPE == "local" and remote_url:
try:
from app.services.video_gen import download_video
date_dir = datetime.now().strftime("%Y/%m/%d")
dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, date_dir)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, f"{record.id}.mp4")
await download_video(remote_url, dest)
record.video_url = f"/generate/videos/{date_dir}/{record.id}.mp4"
cover_url, _cover_storage_path = await async_create_video_cover_for_local_video(
record_id=record.id,
video_path=dest,
date_dir=date_dir,
log_prefix=f"SeedanceCallback视频封面生成 record_id={record.id}",
)
record.video_cover_url = cover_url
storage_path = dest
file_size_bytes = safe_file_size(dest)
except Exception as e:
logger.warning(f"Callback download failed, using remote URL: {e}")
record.video_url = remote_url
else:
record.video_url = remote_url
record.generated_at = datetime.now()
if record.video_url:
await record_generation_record_generated_resource(
db,
record,
resource_url=record.video_url,
storage_path=storage_path,
file_size_bytes=file_size_bytes,
remote_url=remote_url,
generated_at=record.generated_at,
) )
# Extract video token usage from callback else:
usage = data.get("usage", {}) usage = data.get("usage", {}) if isinstance(data.get("usage"), dict) else {}
if usage: from app.services.video_queue import handle_generation_record_video_succeeded
record.video_tokens_used = usage.get("total_tokens", 0) try:
await sync_generation_record_media_token_snapshot(db, record, provider_response=data) entered_upscale = await handle_generation_record_video_succeeded(
# Log callback response db,
from app.services.video_gen import _log_video_response record,
_log_video_response(record.id, data) remote_url=remote_url,
# Notify user provider_response=data,
from app.services.notification import create_notification video_tokens=usage.get("total_tokens", 0),
from app.api.v1.notifications import push_notification_to_user )
notif = await create_notification( except Exception as exc:
db, record.user_id, "视频生成完成", await db.rollback()
"您的视频已生成完成,可以查看了。", "video", record.id, result = await db.execute(
) select(GenerationRecord).where(GenerationRecord.id == record.id).with_for_update().limit(1)
await push_notification_to_user(record.user_id, notif) )
failed_record = result.scalar_one_or_none()
if failed_record:
failed_record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once(
db, record=failed_record, error_message=f"视频结果下载失败: {exc}"
)
entered_upscale = False
from app.services.video_gen import _log_video_response
_log_video_response(record.id, data)
if not entered_upscale and record.status == "completed":
from app.services.notification import create_notification
from app.api.v1.notifications import push_notification_to_user
notif = await create_notification(
db, record.user_id, "视频生成完成",
"您的视频已生成完成,可以查看了。", "video", record.id,
)
await push_notification_to_user(record.user_id, notif)
elif task_status == "failed": elif task_status == "failed":
error_message = data.get("error", "视频生成失败") error_message = data.get("error", "视频生成失败")
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once( await mark_generation_record_failed_and_refund_once(
db, db,
record=record, record=record,
+280 -139
View File
@@ -1,7 +1,8 @@
from datetime import datetime, timezone from datetime import datetime
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query
from sqlalchemy import and_, select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db from app.dependencies import get_current_user, get_db
@@ -19,25 +20,35 @@ from app.schemas.generation_ai import (
GenerationAITaskListOut, GenerationAITaskListOut,
GenerationAITaskOut, GenerationAITaskOut,
) )
from app.services.generation_ai_service import ( from app.services.generation.ai.service import (
create_async_generation_task, build_task_out_list,
list_generation_ai_engine_options, list_generation_ai_engine_options,
list_async_generation_tasks, list_async_generation_tasks,
list_generation_history_day_items, list_generation_history_day_items,
list_generation_history_grouped_days, list_generation_history_grouped_days,
record_to_out,
soft_delete_chat_generation_task,
) )
from app.services.generation_billing_service import ( from app.enums.generation_task import ChatGenerationPipelineStage, ChatGenerationTaskStatus, GenerationMode
from app.services.generation.ai.task_create_service import (
GenerationTaskCreateResult,
create_generation_task_group,
enqueue_created_generation_tasks,
find_existing_top_level_task,
)
from app.services.generation.ai.task_group_service import (
aggregate_main_task_status,
load_children_map,
soft_delete_child_task,
soft_delete_top_level_task_group,
)
from app.services.generation.billing_service import (
OWNER_CHAT_GENERATION_TASK, OWNER_CHAT_GENERATION_TASK,
charge_generation_media_by_params, charge_generation_media_by_params,
get_next_credit_attempt_no, get_next_credit_attempt_no,
) )
from app.services.generation_history_delete_service import batch_delete_generation_history_items from app.services.generation.history_delete_service import batch_delete_generation_history_items
from app.services.generation_log_service import log_task_event from app.services.generation.log_service import log_task_event
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.operation_log_service import log_operation_event
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
router = APIRouter( router = APIRouter(
@@ -146,6 +157,7 @@ async def create_task(
..., ...,
description=( description=(
"AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数。" "AI生成任务创建参数。gen_type=image 时使用图片参数;gen_type=video 时使用视频参数。"
"generation_count 为客户端本次选择的生成数量,默认1,后端会按引擎开关和数量上限校验。"
"枚举:gen_type=image/videomedia_references[].type=image/video/audio" "枚举:gen_type=image/videomedia_references[].type=image/video/audio"
"media_references[].source=upload_resource/private_portrait_asset/空;" "media_references[].source=upload_resource/private_portrait_asset/空;"
"media_references[].role=first_frame/last_frame/reference_image/reference_video/reference_audio。" "media_references[].role=first_frame/last_frame/reference_image/reference_video/reference_audio。"
@@ -157,34 +169,88 @@ async def create_task(
if celery_app is None: if celery_app is None:
raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker") raise HTTPException(status_code=503, detail="Celery未启用:请配置 REDIS_URL 或 CELERY_BROKER_URL 后启动 worker")
task = await create_async_generation_task(db, current_user, req) try:
await db.commit() create_result = await create_generation_task_group(db, current_user, req)
top_level_task_id = str(create_result.top_level_task_id)
enqueue_task_ids = list(create_result.enqueue_task_ids)
await db.commit()
except IntegrityError:
# 并发重复请求可能同时通过预查询;唯一索引负责兜底。
# 回滚本次任务和计费后,按幂等键返回已经成功提交的顶层任务。
await db.rollback()
existing = await find_existing_top_level_task(
db,
user_id=current_user.id,
idempotency_key=req.idempotency_key,
)
if not existing:
raise
create_result = GenerationTaskCreateResult(
top_level_task_id=str(existing.id),
generation_count=int(existing.generation_count or 1),
gen_type=str(existing.gen_type),
created=False,
)
top_level_task_id = str(existing.id)
enqueue_task_ids = []
if create_result.created:
log_operation_event(
domain="generation_ai_batch",
event_type="BATCH_COMMIT_SUCCESS",
event_status="success",
source="api",
user_id=current_user.id,
group_id=top_level_task_id,
task_id=top_level_task_id,
detail={
"gen_type": create_result.gen_type,
"generation_count": create_result.generation_count,
"child_task_ids": create_result.child_task_ids,
"physical_files_deleted": False,
},
)
await log_task_event( await log_task_event(
task, task_id=top_level_task_id,
event_type="TASK_CREATED", event_type=(
to_status="generating", "TASK_CREATED" if create_result.created else "IDEMPOTENCY_HIT"
to_stage="queued", ),
detail={"gen_type": task.gen_type}, to_status="generating" if create_result.created else None,
to_stage="queued" if create_result.created else None,
detail={
"gen_type": create_result.gen_type,
"generation_count": create_result.generation_count,
"child_task_ids": create_result.child_task_ids,
"created": create_result.created,
},
) )
from app.tasks.generation_create_tasks import chatapi_create_generation_task failed_enqueue_ids: list[str] = []
if create_result.created and enqueue_task_ids:
try: failed_enqueue_ids = await enqueue_created_generation_tasks(
chatapi_create_generation_task.delay(task.id)
except Exception as exc:
await mark_chat_generation_task_failed_and_refund_once(
db, db,
task_id=task.id, task_ids=enqueue_task_ids,
error_message=f"任务队列投递失败: {exc}",
pipeline_stage="failed",
) )
await db.commit()
raise HTTPException(status_code=503, detail="任务队列投递失败,请稍后重试")
refs = await resolve_private_portrait_reference_display_urls(db, record_to_out(task).media_references, user_id=current_user.id)
return record_to_out(task, media_references=refs)
result = await db.execute(
select(ChatGenerationTask).where(
ChatGenerationTask.id == top_level_task_id,
ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.deleted_at.is_(None),
).limit(1)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务创建后未找到")
output = await build_task_out_list(
db,
[task],
viewer_user_id=current_user.id,
)
if failed_enqueue_ids and len(failed_enqueue_ids) == len(enqueue_task_ids):
raise HTTPException(status_code=503, detail="任务已创建,但任务队列投递失败,请稍后重试")
return output[0]
@router.get( @router.get(
"/tasks", "/tasks",
@@ -261,10 +327,8 @@ async def list_tasks(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
is_admin = False is_admin = current_user.user_type == "admin"
if current_user.user_type == 'admin': if not is_admin:
is_admin = True
else:
user_id = current_user.id user_id = current_user.id
total, items = await list_async_generation_tasks( total, items = await list_async_generation_tasks(
@@ -280,24 +344,17 @@ async def list_tasks(
created_start=created_start, created_start=created_start,
created_end=created_end, created_end=created_end,
) )
# 同一个 API 同时服务管理后台和客户端:
# ====================== 在这里加排序(最新在前)====================== # - 管理员保持数据库倒序,最新记录在列表上方;
if not is_admin: # - 普通用户先查询最新一页,再仅反转当前页,聊天消息从旧到新排列。
# 按 created_at 降序(没有则用 id 降序) items_for_output = items if is_admin else list(reversed(items))
items_sorted = sorted( out_items = await build_task_out_list(
items,
key=lambda x: x.created_at if x.created_at is not None else x.id,
reverse=False # 升序
)
else:
items_sorted = items
refs_map = await batch_resolve_private_portrait_reference_display_urls(
db, db,
{item.id: record_to_out(task=item, is_admin=is_admin).media_references for item in items_sorted}, items_for_output,
user_id=None if is_admin else current_user.id, is_admin=is_admin,
viewer_user_id=None if is_admin else current_user.id,
) )
return GenerationAITaskListOut(total=total, items=[record_to_out(task=i, is_admin=is_admin, media_references=refs_map.get(i.id)) for i in items_sorted]) return GenerationAITaskListOut(total=total, items=out_items)
@router.get( @router.get(
"/history", "/history",
@@ -513,8 +570,9 @@ async def list_history_day_items(
summary="获取AI生成任务详情", summary="获取AI生成任务详情",
description=( description=(
"根据任务ID获取当前登录用户的AI生成任务详情。" "根据任务ID获取当前登录用户的AI生成任务详情。"
"只能查询当前用户自己的任务,且只查询 generation_mode=chatapi_async 的任务" "支持 chatapi_async、chatapi_main 和未删除的 chatapi_child"
"如果任务不存在或不属于当前用户,返回404" "查询 chatapi_main 时返回按 generation_index 升序排列的 child_items"
"已软删除 child 只在父任务 child_items 中保留槽位,不能通过 child ID 单独查询。"
), ),
responses={ responses={
200: { 200: {
@@ -541,17 +599,19 @@ async def get_task(
select(ChatGenerationTask).where( select(ChatGenerationTask).where(
ChatGenerationTask.id == task_id, ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == current_user.id, ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == "chatapi_async", ).limit(1)
ChatGenerationTask.deleted_at.is_(None),
)
.limit(1)
) )
task = result.scalar_one_or_none() task = result.scalar_one_or_none()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
refs = await resolve_private_portrait_reference_display_urls(db, record_to_out(task).media_references, user_id=current_user.id) if task.deleted_at is not None:
return record_to_out(task, media_references=refs) raise HTTPException(status_code=404, detail="任务不存在")
output = await build_task_out_list(
db,
[task],
viewer_user_id=current_user.id,
)
return output[0]
@router.delete( @router.delete(
"/tasks/{task_id}", "/tasks/{task_id}",
@@ -587,38 +647,33 @@ async def delete_task(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
result = await db.execute( mode_result = await db.execute(
select(ChatGenerationTask).where( select(ChatGenerationTask.generation_mode).where(
ChatGenerationTask.id == task_id, ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == current_user.id, ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == "chatapi_async", ).limit(1)
ChatGenerationTask.deleted_at.is_(None), )
generation_mode = mode_result.scalar_one_or_none()
if generation_mode == GenerationMode.CHATAPI_CHILD.value:
freed_size_bytes = await soft_delete_child_task(
db,
child_task_id=task_id,
user_id=current_user.id,
) )
.limit(1) else:
) freed_size_bytes = await soft_delete_top_level_task_group(
task = result.scalar_one_or_none() db,
if not task: task_id=task_id,
raise HTTPException(status_code=404, detail="任务不存在") user_id=current_user.id,
)
if task.status == "generating": await db.commit()
raise HTTPException(status_code=400, detail="当前任务正在生成中,暂不能删除")
deleted_at = datetime.now(timezone.utc)
freed_size_bytes = await soft_delete_chat_generation_task(
db,
task=task,
deleted_at=deleted_at,
)
await db.flush()
return GenerationAITaskDeleteOut( return GenerationAITaskDeleteOut(
message="任务已删除", message="任务已删除",
task_id=task.id, task_id=task_id,
deleted=True, deleted=True,
freed_size_bytes=freed_size_bytes, freed_size_bytes=freed_size_bytes,
) )
@router.post( @router.post(
"/tasks/{task_id}/retry", "/tasks/{task_id}/retry",
response_model=GenerationAIRetryOut, response_model=GenerationAIRetryOut,
@@ -664,74 +719,160 @@ async def retry_task(
select(ChatGenerationTask).where( select(ChatGenerationTask).where(
ChatGenerationTask.id == task_id, ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == current_user.id, ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == "chatapi_async",
ChatGenerationTask.deleted_at.is_(None), ChatGenerationTask.deleted_at.is_(None),
) ).with_for_update().limit(1)
.with_for_update()
.limit(1)
) )
task = result.scalar_one_or_none() task = result.scalar_one_or_none()
if not task: if not task:
raise HTTPException(status_code=404, detail="任务不存在") raise HTTPException(status_code=404, detail="任务不存在")
if task.status != "failed":
raise HTTPException(status_code=400, detail="只有失败任务可以重试") retry_targets: list[ChatGenerationTask]
retrying_group_children = False
if task.generation_mode == GenerationMode.CHATAPI_MAIN.value:
children_map = await load_children_map(db, [task.id], include_deleted=False)
children = children_map.get(task.id, [])
if task.gen_type == "video":
retry_targets = [
child for child in children
if child.status == ChatGenerationTaskStatus.FAILED.value
]
retrying_group_children = True
if not retry_targets:
raise HTTPException(status_code=400, detail="当前视频任务组没有可重试的失败子任务")
elif children:
# 图片供应商全部成功后才会拆子任务;已有子任务时只允许重试下载,
# 不能再次扣费并覆盖原有生成序号。
retry_targets = [
child for child in children
if child.status == ChatGenerationTaskStatus.FAILED.value
and child.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value
and bool(child.remote_result_url)
]
retrying_group_children = True
if not retry_targets:
raise HTTPException(status_code=400, detail="当前图片任务组没有可重试的下载失败子任务")
else:
# 图片批次在供应商阶段整批失败时尚未创建子任务,可整批重新生成并重新计费。
if task.status != ChatGenerationTaskStatus.FAILED.value:
raise HTTPException(status_code=400, detail="只有失败任务可以重试")
retry_targets = [task]
else:
if task.status != ChatGenerationTaskStatus.FAILED.value:
raise HTTPException(status_code=400, detail="只有失败任务可以重试")
retry_targets = [task]
upscale_failed_ids = [
str(target.id)
for target in retry_targets
if target.pipeline_stage == ChatGenerationPipelineStage.UPSCALE_FAILED.value
]
if upscale_failed_ids:
raise HTTPException(
status_code=409,
detail={
"message": "画质增强失败任务不能通过普通生成重试,请由管理员使用视频超分恢复命令处理",
"task_ids": upscale_failed_ids,
},
)
await assert_user_resource_capacity_available(db, current_user.id) await assert_user_resource_capacity_available(db, current_user.id)
enqueue_ids: list[str] = []
download_retry_ids: list[str] = []
for target in retry_targets:
if int(target.retry_count or 0) >= 3:
raise HTTPException(status_code=400, detail=f"任务 {target.id} 已超过最大重试次数")
attempt_no = await get_next_credit_attempt_no( is_download_retry = bool(
db, target.remote_result_url
owner_type=OWNER_CHAT_GENERATION_TASK, and target.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value
owner_id=task.id, )
) if not is_download_retry:
media_billing = await charge_generation_media_by_params( attempt_no = await get_next_credit_attempt_no(
db, db,
user_id=task.user_id, owner_type=OWNER_CHAT_GENERATION_TASK,
record_id=task.id, owner_id=target.id,
gen_type=task.gen_type, )
image_size=task.image_size, quantity = int(target.generation_count or 1) if (
duration=task.duration, target.generation_mode == GenerationMode.CHATAPI_MAIN.value and target.gen_type == "image"
resolution=task.resolution, ) else 1
engine_id=task.engine_id, media_billing = await charge_generation_media_by_params(
project_name="AI生成任务", db,
description_prefix="Chat任务重试", user_id=target.user_id,
owner_type=OWNER_CHAT_GENERATION_TASK, record_id=target.id,
attempt_no=attempt_no, gen_type=target.gen_type,
) image_size=target.image_size,
duration=target.duration,
resolution=target.resolution,
engine_id=target.engine_id,
project_name="AI生成任务",
description_prefix="Chat任务重试",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=attempt_no,
quantity=quantity,
)
target.credits_cost = round(float(target.credits_cost or 0) + media_billing.total_charged, 2)
target.provider_task_id = None
target.seedance_task_id = None
target.remote_result_url = None
target.provider_response_json = None
target.provider_create_claim_token = None
target.provider_create_lease_until = None
target.provider_create_started_at = None
target.image_url = None
target.video_url = None
target.video_cover_url = None
target.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
enqueue_ids.append(str(target.id))
else:
target.pipeline_stage = ChatGenerationPipelineStage.RESULT_READY.value
download_retry_ids.append(str(target.id))
task.status = "generating" target.status = ChatGenerationTaskStatus.GENERATING.value
task.pipeline_stage = "queued" target.error_message = None
task.error_message = None target.poll_count = 0
task.poll_count = 0 target.last_poll_at = None
task.last_poll_at = None target.generated_at = None
task.provider_task_id = None target.retry_count = int(target.retry_count or 0) + 1
task.seedance_task_id = None
task.remote_result_url = None
task.provider_response_json = None
task.image_url = None
task.video_url = None
task.video_cover_url = None
task.generated_at = None
task.credits_cost = round(float(task.credits_cost or 0) + media_billing.total_charged, 2)
if retrying_group_children:
await db.flush()
await aggregate_main_task_status(db, parent_task_id=str(task.id))
refreshed_task_id = str(task.id)
await db.commit() await db.commit()
from app.tasks.generation_create_tasks import chatapi_create_generation_task failed_enqueue_ids = await enqueue_created_generation_tasks(db, task_ids=enqueue_ids) if enqueue_ids else []
failed_download_enqueue_ids: list[str] = []
if download_retry_ids:
from app.tasks.generation_download_tasks import enqueue_download_task
for target_id in download_retry_ids:
target_result = await db.execute(
select(ChatGenerationTask).where(
ChatGenerationTask.id == target_id,
ChatGenerationTask.deleted_at.is_(None),
).limit(1)
)
target = target_result.scalar_one_or_none()
if not target or not await enqueue_download_task(db, target, recover=True, reason="manual_retry"):
failed_download_enqueue_ids.append(target_id)
try: requested_enqueue_count = len(enqueue_ids) + len(download_retry_ids)
chatapi_create_generation_task.delay(task.id) failed_total_count = len(failed_enqueue_ids) + len(failed_download_enqueue_ids)
except Exception as exc: if requested_enqueue_count and failed_total_count == requested_enqueue_count:
await mark_chat_generation_task_failed_and_refund_once( raise HTTPException(status_code=503, detail="任务状态已重置,但任务队列投递全部失败,将由恢复任务继续处理")
db,
task_id=task.id,
error_message=f"任务队列投递失败: {exc}",
pipeline_stage="failed",
)
await db.commit()
raise HTTPException(status_code=503, detail="任务队列投递失败,请稍后重试")
refreshed = await db.execute(
select(ChatGenerationTask).where(ChatGenerationTask.id == refreshed_task_id).limit(1)
)
refreshed_task = refreshed.scalar_one_or_none()
if not refreshed_task:
raise HTTPException(status_code=404, detail="任务不存在")
return GenerationAIRetryOut( return GenerationAIRetryOut(
id=task.id, id=refreshed_task.id,
status=task.status, status=refreshed_task.status,
pipeline_stage=task.pipeline_stage, pipeline_stage=refreshed_task.pipeline_stage,
message="任务已重新扣费并重新投递", message=(
f"请求重试 {len(retry_targets)} 个任务,成功投递 {max(0, requested_enqueue_count - failed_total_count)} 个,"
f"投递失败 {failed_total_count}"
),
) )
@@ -45,5 +45,9 @@ async def list_active_engines(
"supported_sizes": sizes, "supported_sizes": sizes,
"default_size": e.default_size, "default_size": e.default_size,
"max_image_count": e.max_image_count, "max_image_count": e.max_image_count,
"multi_generation_enabled": bool(getattr(e, "multi_generation_enabled", False)),
"max_generation_count": int(getattr(e, "max_generation_count", 1) or 1),
"multi_image_max_images": int(getattr(e, "multi_image_max_images", 15) or 15),
"max_reference_image_count": int(getattr(e, "max_reference_image_count", 14) or 0),
}) })
return {"items": items} return {"items": items}
+5
View File
@@ -10,6 +10,7 @@ from app.models.project import Project
from app.models.generation_record import GenerationRecord from app.models.generation_record import GenerationRecord
from app.schemas.project import ProjectCreate, ProjectOut from app.schemas.project import ProjectCreate, ProjectOut
from app.services.resource_accounting_service import soft_delete_generation_record_resources from app.services.resource_accounting_service import soft_delete_generation_record_resources
from app.services.video_upscale.guard_service import assert_no_recoverable_failed_upscale_tasks
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
@@ -88,6 +89,10 @@ async def delete_project(
) )
records = list(records_result.scalars().all()) records = list(records_result.scalars().all())
record_ids = [record.id for record in records] record_ids = [record.id for record in records]
await assert_no_recoverable_failed_upscale_tasks(
db,
generation_record_ids=record_ids,
)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
project.deleted_at = now project.deleted_at = now
+4 -2
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime, timezone, timedelta
CST = timezone(timedelta(hours=8))
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import select
@@ -405,7 +407,7 @@ async def export_team_credit_records(
from urllib.parse import quote from urllib.parse import quote
output.seek(0) output.seek(0)
safe_team_name = team.name or "team" safe_team_name = team.name or "team"
filename = f"团队积分_{safe_team_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" filename = f"团队积分_{safe_team_name}_{datetime.now(CST).strftime('%Y%m%d_%H%M%S')}.csv"
encoded_filename = quote(filename) encoded_filename = quote(filename)
return StreamingResponse( return StreamingResponse(
iter([output.getvalue()]), iter([output.getvalue()]),
@@ -52,6 +52,8 @@ async def list_active_engines(
"max_image_count": e.max_image_count, "max_image_count": e.max_image_count,
"max_video_count": e.max_video_count, "max_video_count": e.max_video_count,
"max_audio_count": e.max_audio_count, "max_audio_count": e.max_audio_count,
"multi_generation_enabled": bool(getattr(e, "multi_generation_enabled", False)),
"max_generation_count": int(getattr(e, "max_generation_count", 1) or 1),
"supports_first_last_frame": e.supports_first_last_frame, "supports_first_last_frame": e.supports_first_last_frame,
"supports_universal_reference": e.supports_universal_reference, "supports_universal_reference": e.supports_universal_reference,
}) })
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
from __future__ import annotations
import argparse
import asyncio
import json
from datetime import datetime, timezone
from sqlalchemy import or_, select
from app.config import settings
from app.enums.video_upscale import LOCAL_PROCESSOR_KEYS, VideoUpscaleTaskStatus
from app.models.base import async_session
from app.models.chat_generation_task import ChatGenerationTask
from app.models.generation_record import GenerationRecord
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.video_upscale_task import VideoUpscaleTask
from app.services.video_upscale.media_service import is_valid_file
from app.services.video_upscale.task_service import reset_failed_upscale_task_for_manual_retry
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="人工恢复视频超分任务")
parser.add_argument("--task-id", action="append", default=[], help="ChatGenerationTask.id,可重复传入")
parser.add_argument("--task-ids", default="", help="逗号分隔的 ChatGenerationTask.id")
parser.add_argument("--generation-record-id", action="append", default=[], help="GenerationRecord.id,可重复传入")
parser.add_argument("--generation-record-ids", default="", help="逗号分隔的 GenerationRecord.id")
parser.add_argument("--project-id", action="append", default=[], help="Project.id,可重复传入")
parser.add_argument("--generation-mode", default="", help="按 ChatGenerationTask.generation_mode 筛选")
parser.add_argument("--module-owner-id", default="", help="ModuleGenerationProject.id")
parser.add_argument("--shot-task-set-id", default="", help="ShotReplicateTaskSet.id")
parser.add_argument("--shot-segment-id", action="append", default=[], help="ShotReplicateSegment.id,可重复传入")
parser.add_argument("--failed-only", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--limit", type=int, default=100)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--enqueue", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--force-resubmit", action="store_true", help="远程任务清空 provider task/result 后从 source.mp4 重新提交")
return parser
async def _collect_chat_task_ids(db, args: argparse.Namespace) -> list[str]:
ids = [str(item).strip() for item in args.task_id if str(item).strip()]
ids.extend(item.strip() for item in str(args.task_ids or "").split(",") if item.strip())
project_ids: list[str] = [str(args.module_owner_id).strip()] if args.module_owner_id else []
segment_ids = [str(item).strip() for item in args.shot_segment_id if str(item).strip()]
if args.shot_task_set_id or segment_ids:
query = select(ShotReplicateSegment.module_project_id).where(
ShotReplicateSegment.deleted_at.is_(None),
ShotReplicateSegment.module_project_id.is_not(None),
)
if args.shot_task_set_id:
query = query.where(ShotReplicateSegment.task_set_id == str(args.shot_task_set_id).strip())
if segment_ids:
query = query.where(ShotReplicateSegment.id.in_(segment_ids))
result = await db.execute(query)
project_ids.extend(str(value) for value in result.scalars().all() if value)
project_ids = list(dict.fromkeys(item for item in project_ids if item))
if project_ids:
result = await db.execute(
select(ModuleGenerationStep.chat_task_id).where(
ModuleGenerationStep.project_id.in_(project_ids),
ModuleGenerationStep.chat_task_id.isnot(None),
ModuleGenerationStep.deleted_at.is_(None),
)
)
ids.extend(str(value) for value in result.scalars().all() if value)
return list(dict.fromkeys(ids))
async def _collect_generation_record_ids(db, args: argparse.Namespace) -> list[str]:
ids = [str(item).strip() for item in args.generation_record_id if str(item).strip()]
ids.extend(item.strip() for item in str(args.generation_record_ids or "").split(",") if item.strip())
project_ids = [str(item).strip() for item in args.project_id if str(item).strip()]
if project_ids:
result = await db.execute(
select(GenerationRecord.id).where(
GenerationRecord.project_id.in_(project_ids),
GenerationRecord.deleted_at.is_(None),
)
)
ids.extend(str(value) for value in result.scalars().all() if value)
return list(dict.fromkeys(ids))
def _owner_preview(upscale: VideoUpscaleTask, chat: ChatGenerationTask | None, record: GenerationRecord | None) -> dict:
owner = chat or record
return {
"upscale_task_id": upscale.id,
"owner_type": "chat_generation_task" if chat else "generation_record",
"owner_id": owner.id if owner else None,
"chat_task_id": chat.id if chat else None,
"generation_record_id": record.id if record else None,
"project_id": record.project_id if record else None,
"generation_mode": chat.generation_mode if chat else None,
"status": upscale.status,
"stage": upscale.stage,
"processor_key": upscale.processor_key,
"source_local_path": upscale.source_local_path,
"provider_task_id": upscale.provider_task_id,
"provider_output_url_expires_at": upscale.provider_output_url_expires_at,
}
async def _run(args: argparse.Namespace) -> dict:
async with async_session() as db:
chat_ids = await _collect_chat_task_ids(db, args)
record_ids = await _collect_generation_record_ids(db, args)
query = (
select(VideoUpscaleTask, ChatGenerationTask, GenerationRecord)
.outerjoin(ChatGenerationTask, ChatGenerationTask.id == VideoUpscaleTask.chat_generation_task_id)
.outerjoin(GenerationRecord, GenerationRecord.id == VideoUpscaleTask.generation_record_id)
.where(
or_(
(ChatGenerationTask.id.isnot(None) & ChatGenerationTask.deleted_at.is_(None)),
(GenerationRecord.id.isnot(None) & GenerationRecord.deleted_at.is_(None)),
)
)
.order_by(VideoUpscaleTask.updated_at.asc())
.limit(max(1, min(int(args.limit or 100), 1000)))
)
owner_filters = []
if chat_ids:
owner_filters.append(VideoUpscaleTask.chat_generation_task_id.in_(chat_ids))
if record_ids:
owner_filters.append(VideoUpscaleTask.generation_record_id.in_(record_ids))
if owner_filters:
query = query.where(or_(*owner_filters))
if args.generation_mode:
query = query.where(ChatGenerationTask.generation_mode == args.generation_mode)
if args.failed_only:
query = query.where(VideoUpscaleTask.status == VideoUpscaleTaskStatus.FAILED.value)
result = await db.execute(query)
rows = result.all()
preview = [_owner_preview(upscale, chat, record) for upscale, chat, record in rows]
if args.dry_run or not args.enqueue:
return {"dry_run": True, "matched": len(preview), "items": preview}
from app.tasks.video_upscale_tasks import download_remote_result, execute_local, finalize, poll_remote, submit_remote
enqueued = []
for upscale, chat, record in rows:
reset = await reset_failed_upscale_task_for_manual_retry(
db,
upscale_task_id=upscale.id,
force_resubmit=bool(args.force_resubmit),
)
if is_valid_file(reset.final_local_path) and not args.force_resubmit:
action = "finalize"
finalize.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_LOCAL_QUEUE)
elif reset.processor_key in LOCAL_PROCESSOR_KEYS:
action = "local"
execute_local.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_LOCAL_QUEUE)
else:
expires_at = reset.provider_output_url_expires_at
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
remaining = (expires_at - datetime.now(timezone.utc)).total_seconds() if expires_at else None
if reset.provider_output_url and remaining is not None and remaining >= 2 * 3600 and not args.force_resubmit:
action = "download"
download_remote_result.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
elif reset.provider_task_id and not args.force_resubmit:
action = "poll"
poll_remote.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
else:
action = "submit"
submit_remote.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
owner = chat or record
enqueued.append(
{
"upscale_task_id": reset.id,
"owner_type": "chat_generation_task" if chat else "generation_record",
"owner_id": owner.id if owner else None,
"action": action,
}
)
return {"dry_run": False, "matched": len(preview), "enqueued": enqueued}
def main() -> None:
args = _parser().parse_args()
result = asyncio.run(_run(args))
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
if __name__ == "__main__":
main()
+23
View File
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
LLM_API_KEY: str = "" LLM_API_KEY: str = ""
LLM_MODEL: str = "gpt-4o" LLM_MODEL: str = "gpt-4o"
LLM_MOCK: bool = True LLM_MOCK: bool = True
LLM_MEDIA_AS_BASE64: bool = True
ENCRYPTION_KEY: str = "changeme-32bytes-base64-key-here!!" ENCRYPTION_KEY: str = "changeme-32bytes-base64-key-here!!"
@@ -95,6 +96,28 @@ class Settings(BaseSettings):
# - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。 # - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。
# - VIDEO_COVER_TIMEOUT_SECONDS 必须较短,避免 ffmpeg 异常卡住下载 worker。 # - VIDEO_COVER_TIMEOUT_SECONDS 必须较短,避免 ffmpeg 异常卡住下载 worker。
FFMPEG_BIN: str = "" FFMPEG_BIN: str = ""
# 视频超分配置。
# 本地处理器始终复用 FFMPEG_BIN,不允许由管理后台覆盖可执行文件路径。
VOLC_API_KEY: str = ""
VOLC_MEDIAKIT_API_BASE: str = "https://mediakit.cn-beijing.volces.com"
VIDEO_UPSCALE_LOCAL_QUEUE: str = "gen_video_upscale_local"
VIDEO_UPSCALE_REMOTE_QUEUE: str = "gen_video_upscale_remote"
VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS: int = 3600
VIDEO_UPSCALE_MAX_ATTEMPTS: int = 3
VIDEO_UPSCALE_REMOTE_POLL_INTERVAL_SECONDS: int = 30
VIDEO_UPSCALE_REMOTE_POLL_TIMEOUT_SECONDS: int = 7200
VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS: int = 30
VIDEO_UPSCALE_REMOTE_RESULT_DOWNLOAD_TIMEOUT_SECONDS: int = 600
VIDEO_UPSCALE_REMOTE_URL_PROBE_THRESHOLD_SECONDS: int = 600
VIDEO_UPSCALE_REMOTE_URL_PROBE_CONNECT_TIMEOUT_SECONDS: int = 3
VIDEO_UPSCALE_REMOTE_URL_PROBE_READ_TIMEOUT_SECONDS: int = 5
VIDEO_UPSCALE_LOCAL_SIGNED_URL_EXPIRE_SECONDS: int = 7200
VIDEO_UPSCALE_TASK_LEASE_SECONDS: int = 30 * 60
VIDEO_UPSCALE_RETRY_BACKOFF_SECONDS: int = 60
VIDEO_UPSCALE_RECOVERY_BATCH_SIZE: int = 50
VIDEO_UPSCALE_RECOVERY_LOCK_KEY: str = "vg:celery:video_upscale_recovery_lock"
VIDEO_COVER_SEEK_TIME: str = "00:00:01" VIDEO_COVER_SEEK_TIME: str = "00:00:01"
VIDEO_COVER_FALLBACK_SEEK_TIME: str = "00:00:00" VIDEO_COVER_FALLBACK_SEEK_TIME: str = "00:00:00"
VIDEO_COVER_WIDTH: int = 720 VIDEO_COVER_WIDTH: int = 720
+1
View File
@@ -18,3 +18,4 @@ from app.enums.celery_queue import *
from app.enums.audio_reference import * from app.enums.audio_reference import *
from app.enums.private_portrait import * from app.enums.private_portrait import *
from app.enums.generation_provider import *
+8
View File
@@ -5,6 +5,8 @@ class CeleryQueue(str, Enum):
GEN_CHATAPI_CREATE = "gen_chatapi_create" GEN_CHATAPI_CREATE = "gen_chatapi_create"
GEN_PROVIDER_POLL = "gen_provider_poll" GEN_PROVIDER_POLL = "gen_provider_poll"
GEN_RESULT_DOWNLOAD = "gen_result_download" GEN_RESULT_DOWNLOAD = "gen_result_download"
GEN_VIDEO_UPSCALE_LOCAL = "gen_video_upscale_local"
GEN_VIDEO_UPSCALE_REMOTE = "gen_video_upscale_remote"
GEN_RECOVERY = "gen_recovery" GEN_RECOVERY = "gen_recovery"
GEN_PRIVATE_PORTRAIT = "gen_private_portrait" GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
DEFAULT = "default" DEFAULT = "default"
@@ -16,6 +18,12 @@ class CeleryTaskName(str, Enum):
DOWNLOAD_GENERATION_RESULT = "generation.download_generation_result_task" DOWNLOAD_GENERATION_RESULT = "generation.download_generation_result_task"
RECOVER_DOWNLOAD = "generation.recover_download_tasks_once" RECOVER_DOWNLOAD = "generation.recover_download_tasks_once"
RECOVER_GENERATION = "generation.recover_generation_tasks_once" RECOVER_GENERATION = "generation.recover_generation_tasks_once"
VIDEO_UPSCALE_EXECUTE_LOCAL = "video_upscale.execute_local"
VIDEO_UPSCALE_SUBMIT_REMOTE = "video_upscale.submit_remote"
VIDEO_UPSCALE_POLL_REMOTE = "video_upscale.poll_remote"
VIDEO_UPSCALE_DOWNLOAD_REMOTE_RESULT = "video_upscale.download_remote_result"
VIDEO_UPSCALE_FINALIZE = "video_upscale.finalize"
VIDEO_UPSCALE_RECOVER = "video_upscale.recover_once"
DISPATCH_DUE_POLL = "generation.dispatch_due_poll_tasks" DISPATCH_DUE_POLL = "generation.dispatch_due_poll_tasks"
STARTUP_RECOVERY = "recovery.startup_recovery_once" STARTUP_RECOVERY = "recovery.startup_recovery_once"
MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once" MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once"
+4
View File
@@ -100,3 +100,7 @@ VIDEO_SCHEMA_MAX_SECTION_COUNT = 40
VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION = 80 VIDEO_SCHEMA_MAX_FIELD_COUNT_PER_SECTION = 80
VIDEO_SCHEMA_MAX_TIME_RULE_COUNT = 30 VIDEO_SCHEMA_MAX_TIME_RULE_COUNT = 30
VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE = 12 VIDEO_SCHEMA_MAX_SEGMENT_COUNT_PER_RULE = 12
MIN_GENERATION_COUNT = 1
MAX_GENERATION_COUNT = 5
+28 -7
View File
@@ -45,17 +45,28 @@ GENERATION_HISTORY_MODULE_SOURCES: tuple[GenerationHistorySourceEnum, ...] = (
"""需要回填 module_generation_projects/module_generation_steps 的模块来源集合。""" """需要回填 module_generation_projects/module_generation_steps 的模块来源集合。"""
GENERATION_HISTORY_SOURCE_TO_TASK_MODE: dict[GenerationHistorySourceEnum, GenerationMode] = { GENERATION_HISTORY_SOURCE_TO_TASK_MODES: dict[GenerationHistorySourceEnum, tuple[GenerationMode, ...]] = {
GenerationHistorySourceEnum.CHAT_TASK: GenerationMode.CHATAPI_ASYNC, GenerationHistorySourceEnum.CHAT_TASK: (
GenerationHistorySourceEnum.HOT_OPENING_REPLICATE: GenerationMode.HOT_OPENING_REPLICATE, GenerationMode.CHATAPI_ASYNC,
GenerationHistorySourceEnum.SHOT_REPLICATE: GenerationMode.SHOT_REPLICATE, GenerationMode.CHATAPI_CHILD,
),
GenerationHistorySourceEnum.HOT_OPENING_REPLICATE: (GenerationMode.HOT_OPENING_REPLICATE,),
GenerationHistorySourceEnum.SHOT_REPLICATE: (GenerationMode.SHOT_REPLICATE,),
} }
"""history_source 到 ChatGenerationTask.generation_mode 的映射。""" """history_source 到 ChatGenerationTask.generation_mode 集合的映射。"""
GENERATION_HISTORY_SOURCE_TO_TASK_MODE: dict[GenerationHistorySourceEnum, GenerationMode] = {
history_source: task_modes[0]
for history_source, task_modes in GENERATION_HISTORY_SOURCE_TO_TASK_MODES.items()
}
"""兼容旧调用的单一模式映射;新查询应使用 GENERATION_HISTORY_SOURCE_TO_TASK_MODES。"""
GENERATION_HISTORY_TASK_MODE_VALUE_TO_SOURCE: dict[str, GenerationHistorySourceEnum] = { GENERATION_HISTORY_TASK_MODE_VALUE_TO_SOURCE: dict[str, GenerationHistorySourceEnum] = {
task_mode.value: history_source task_mode.value: history_source
for history_source, task_mode in GENERATION_HISTORY_SOURCE_TO_TASK_MODE.items() for history_source, task_modes in GENERATION_HISTORY_SOURCE_TO_TASK_MODES.items()
for task_mode in task_modes
} }
"""ChatGenerationTask.generation_mode 字符串值到 history_source 的映射。""" """ChatGenerationTask.generation_mode 字符串值到 history_source 的映射。"""
@@ -103,11 +114,17 @@ def get_generation_history_source_label(source: GenerationHistorySourceEnum | st
def get_generation_history_task_mode(source: GenerationHistorySourceEnum) -> GenerationMode | None: def get_generation_history_task_mode(source: GenerationHistorySourceEnum) -> GenerationMode | None:
"""获取 history_source 对应的 ChatGenerationTask.generation_mode""" """兼容旧调用:返回 history_source 对应的第一个任务模式"""
return GENERATION_HISTORY_SOURCE_TO_TASK_MODE.get(source) return GENERATION_HISTORY_SOURCE_TO_TASK_MODE.get(source)
def get_generation_history_task_modes(source: GenerationHistorySourceEnum) -> tuple[GenerationMode, ...]:
"""获取 history_source 对应的全部 ChatGenerationTask.generation_mode。"""
return GENERATION_HISTORY_SOURCE_TO_TASK_MODES.get(source, ())
def is_generation_history_chat_task_source(source: GenerationHistorySourceEnum) -> bool: def is_generation_history_chat_task_source(source: GenerationHistorySourceEnum) -> bool:
"""判断当前来源是否走 chat_generation_tasks 表。""" """判断当前来源是否走 chat_generation_tasks 表。"""
@@ -121,3 +138,7 @@ def is_generation_history_module_source(source: GenerationHistorySourceEnum) ->
MAX_BATCH_DELETE_COUNT = 30 MAX_BATCH_DELETE_COUNT = 30
HISTORY_DAY_PAGE_SIZE_MAX = 10
HISTORY_GROUP_ITEM_LIMIT = 10
@@ -0,0 +1,40 @@
from enum import StrEnum
class GenerationProviderResultType(StrEnum):
IMAGE = "image"
VIDEO = "video"
class GenerationProviderTaskPhase(StrEnum):
SUBMITTED = "submitted"
POLLING = "polling"
RESULT_READY = "result_ready"
DOWNLOAD_PENDING = "download_pending"
COMPLETED = "completed"
FAILED = "failed"
class ImageProviderErrorType(StrEnum):
TIMEOUT = "timeout"
NETWORK = "network"
RATE_LIMIT = "rate_limit"
AUTH = "auth"
INVALID_REQUEST = "invalid_request"
CAPABILITY_MISMATCH = "capability_mismatch"
CONTENT_REJECTED = "content_rejected"
PROVIDER_INTERNAL = "provider_internal"
INVALID_RESPONSE = "invalid_response"
UNKNOWN = "unknown"
IMAGE_MULTI_OUTPUT_MIN = 1
IMAGE_MULTI_OUTPUT_MAX = 15
IMAGE_MULTI_REFERENCE_MAX = 14
IMAGE_PROVIDER_CLAIM_LEASE_SECONDS = 10 * 60
MULTI_IMAGE_PROMPT_TEMPLATE = (
"请严格生成恰好{count}张内容相关但画面具有明显差异的图片。"
"每张图片必须作为独立图片分别输出,不要把多个画面拼接到同一张图片中,"
"不要生成九宫格、分镜图、组合图或包含多张子图的单张图片。"
)
@@ -9,6 +9,25 @@ class GenerationStatus(str, Enum):
failed = "failed" failed = "failed"
class GenerationRecordPipelineStage(str, Enum):
"""GenerationRecord 视频生成与超分流水线阶段。"""
CREATING_PROVIDER_TASK = "creating_provider_task"
WAITING_REMOTE = "waiting_remote"
POLLING = "polling"
RESULT_READY = "result_ready"
DOWNLOADING = "downloading"
UPSCALE_QUEUED = "upscale_queued"
UPSCALE_PROCESSING = "upscale_processing"
UPSCALE_POLLING = "upscale_polling"
UPSCALE_DOWNLOADING = "upscale_downloading"
UPSCALE_FINALIZING = "upscale_finalizing"
UPSCALE_RETRY_WAITING = "upscale_retry_waiting"
UPSCALE_FAILED = "upscale_failed"
DONE = "done"
FAILED = "failed"
class GenerationType(str, Enum): class GenerationType(str, Enum):
"""生成类型。""" """生成类型。"""
video = "video" video = "video"
+73 -1
View File
@@ -3,6 +3,8 @@ from enum import Enum
class GenerationMode(str, Enum): class GenerationMode(str, Enum):
CHATAPI_ASYNC = "chatapi_async" CHATAPI_ASYNC = "chatapi_async"
CHATAPI_MAIN = "chatapi_main"
CHATAPI_CHILD = "chatapi_child"
HOT_OPENING_REPLICATE = "hot_opening_replicate" HOT_OPENING_REPLICATE = "hot_opening_replicate"
SHOT_REPLICATE = "shot_replicate" SHOT_REPLICATE = "shot_replicate"
@@ -19,6 +21,15 @@ class ChatGenerationTaskStatus(str, Enum):
FAILED = "failed" FAILED = "failed"
class ChatGenerationDisplayStatus(str, Enum):
PENDING = "pending"
GENERATING = "generating"
COMPLETED = "completed"
FAILED = "failed"
DOWNLOAD_FAILED = "download_failed"
DELETED = "deleted"
class ChatGenerationPipelineStage(str, Enum): class ChatGenerationPipelineStage(str, Enum):
QUEUED = "queued" QUEUED = "queued"
PREPARING = "preparing" PREPARING = "preparing"
@@ -29,6 +40,13 @@ class ChatGenerationPipelineStage(str, Enum):
DOWNLOAD_QUEUED = "download_queued" DOWNLOAD_QUEUED = "download_queued"
DOWNLOADING = "downloading" DOWNLOADING = "downloading"
RETRY_WAITING = "retry_waiting" RETRY_WAITING = "retry_waiting"
UPSCALE_QUEUED = "upscale_queued"
UPSCALE_PROCESSING = "upscale_processing"
UPSCALE_POLLING = "upscale_polling"
UPSCALE_DOWNLOADING = "upscale_downloading"
UPSCALE_FINALIZING = "upscale_finalizing"
UPSCALE_RETRY_WAITING = "upscale_retry_waiting"
UPSCALE_FAILED = "upscale_failed"
DONE = "done" DONE = "done"
FAILED = "failed" FAILED = "failed"
TIMEOUT = "timeout" TIMEOUT = "timeout"
@@ -36,6 +54,31 @@ class ChatGenerationPipelineStage(str, Enum):
class ChatGenerationTaskEventType(str, Enum): class ChatGenerationTaskEventType(str, Enum):
TASK_CREATED = "TASK_CREATED"
IDEMPOTENCY_HIT = "IDEMPOTENCY_HIT"
BATCH_CREATE_START = "BATCH_CREATE_START"
BATCH_MAIN_CREATED = "BATCH_MAIN_CREATED"
BATCH_CHILDREN_CREATED = "BATCH_CHILDREN_CREATED"
BATCH_BILLING_SUCCESS = "BATCH_BILLING_SUCCESS"
BATCH_COMMIT_SUCCESS = "BATCH_COMMIT_SUCCESS"
CHILD_ENQUEUE_START = "CHILD_ENQUEUE_START"
CHILD_ENQUEUE_SUCCESS = "CHILD_ENQUEUE_SUCCESS"
CHILD_ENQUEUE_FAILED = "CHILD_ENQUEUE_FAILED"
IMAGE_MAIN_CLAIM_ACQUIRED = "IMAGE_MAIN_CLAIM_ACQUIRED"
IMAGE_MAIN_CLAIM_REJECTED = "IMAGE_MAIN_CLAIM_REJECTED"
IMAGE_MAIN_CLAIM_EXPIRED = "IMAGE_MAIN_CLAIM_EXPIRED"
IMAGE_BATCH_PROVIDER_START = "IMAGE_BATCH_PROVIDER_START"
IMAGE_BATCH_PROVIDER_SUCCESS = "IMAGE_BATCH_PROVIDER_SUCCESS"
IMAGE_BATCH_PROVIDER_FAILED = "IMAGE_BATCH_PROVIDER_FAILED"
IMAGE_BATCH_SPLIT_START = "IMAGE_BATCH_SPLIT_START"
IMAGE_BATCH_SPLIT_SUCCESS = "IMAGE_BATCH_SPLIT_SUCCESS"
IMAGE_BATCH_SPLIT_FAILED = "IMAGE_BATCH_SPLIT_FAILED"
MAIN_STATUS_AGGREGATED = "MAIN_STATUS_AGGREGATED"
CHILD_RESOURCE_DELETE_START = "CHILD_RESOURCE_DELETE_START"
CHILD_RESOURCE_DELETE_SUCCESS = "CHILD_RESOURCE_DELETE_SUCCESS"
BATCH_GROUP_DELETE_SUCCESS = "BATCH_GROUP_DELETE_SUCCESS"
BATCH_RECOVERY_RECONCILED = "BATCH_RECOVERY_RECONCILED"
PROMPT_CONCAT_START = "PROMPT_CONCAT_START" PROMPT_CONCAT_START = "PROMPT_CONCAT_START"
PROMPT_CONCAT_SUCCESS = "PROMPT_CONCAT_SUCCESS" PROMPT_CONCAT_SUCCESS = "PROMPT_CONCAT_SUCCESS"
@@ -72,6 +115,19 @@ class ChatGenerationTaskEventType(str, Enum):
DOWNLOAD_FAILED = "DOWNLOAD_FAILED" DOWNLOAD_FAILED = "DOWNLOAD_FAILED"
DOWNLOAD_FAILED_NON_RETRYABLE = "DOWNLOAD_FAILED_NON_RETRYABLE" DOWNLOAD_FAILED_NON_RETRYABLE = "DOWNLOAD_FAILED_NON_RETRYABLE"
UPSCALE_SNAPSHOT_MATCHED = "UPSCALE_SNAPSHOT_MATCHED"
UPSCALE_SNAPSHOT_BYPASSED = "UPSCALE_SNAPSHOT_BYPASSED"
UPSCALE_SOURCE_READY = "UPSCALE_SOURCE_READY"
UPSCALE_ENQUEUE = "UPSCALE_ENQUEUE"
UPSCALE_START = "UPSCALE_START"
UPSCALE_REMOTE_SUBMIT = "UPSCALE_REMOTE_SUBMIT"
UPSCALE_REMOTE_POLL = "UPSCALE_REMOTE_POLL"
UPSCALE_REMOTE_RESULT_READY = "UPSCALE_REMOTE_RESULT_READY"
UPSCALE_RETRY_WAITING = "UPSCALE_RETRY_WAITING"
UPSCALE_SUCCESS = "UPSCALE_SUCCESS"
UPSCALE_FAILED = "UPSCALE_FAILED"
UPSCALE_RECOVERY_ENQUEUE = "UPSCALE_RECOVERY_ENQUEUE"
DOWNLOAD_SKIP_TASK_MISSING = "DOWNLOAD_SKIP_TASK_MISSING" DOWNLOAD_SKIP_TASK_MISSING = "DOWNLOAD_SKIP_TASK_MISSING"
DOWNLOAD_SKIP_INVALID_MODE = "DOWNLOAD_SKIP_INVALID_MODE" DOWNLOAD_SKIP_INVALID_MODE = "DOWNLOAD_SKIP_INVALID_MODE"
DOWNLOAD_SKIP_NOT_GENERATING = "DOWNLOAD_SKIP_NOT_GENERATING" DOWNLOAD_SKIP_NOT_GENERATING = "DOWNLOAD_SKIP_NOT_GENERATING"
@@ -87,8 +143,23 @@ class ChatGenerationTaskEventType(str, Enum):
TASK_FAILED = "TASK_FAILED" TASK_FAILED = "TASK_FAILED"
ALLOWED_GENERATION_MODES = { CHAT_TOP_LEVEL_MODES = {
GenerationMode.CHATAPI_ASYNC.value, GenerationMode.CHATAPI_ASYNC.value,
GenerationMode.CHATAPI_MAIN.value,
}
CHAT_RESOURCE_MODES = {
GenerationMode.CHATAPI_ASYNC.value,
GenerationMode.CHATAPI_CHILD.value,
}
CHAT_EXECUTABLE_MODES = {
GenerationMode.CHATAPI_ASYNC.value,
GenerationMode.CHATAPI_CHILD.value,
}
ALLOWED_GENERATION_MODES = {
*CHAT_EXECUTABLE_MODES,
GenerationMode.HOT_OPENING_REPLICATE.value, GenerationMode.HOT_OPENING_REPLICATE.value,
GenerationMode.SHOT_REPLICATE.value, GenerationMode.SHOT_REPLICATE.value,
} }
@@ -98,6 +169,7 @@ FINAL_CHAT_GENERATION_STAGES = {
ChatGenerationPipelineStage.FAILED.value, ChatGenerationPipelineStage.FAILED.value,
ChatGenerationPipelineStage.TIMEOUT.value, ChatGenerationPipelineStage.TIMEOUT.value,
ChatGenerationPipelineStage.DOWNLOAD_FAILED.value, ChatGenerationPipelineStage.DOWNLOAD_FAILED.value,
ChatGenerationPipelineStage.UPSCALE_FAILED.value,
} }
DOWNLOAD_RECOVERABLE_STAGES = { DOWNLOAD_RECOVERABLE_STAGES = {
+17 -6
View File
@@ -38,17 +38,28 @@ RECENT_GENERATION_CHAT_TASK_MODULES: tuple[RecentGenerationModuleEnum, ...] = (
"""来自 chat_generation_tasks 表的模块集合。""" """来自 chat_generation_tasks 表的模块集合。"""
RECENT_GENERATION_MODULE_TO_TASK_MODE: dict[RecentGenerationModuleEnum, GenerationMode] = { RECENT_GENERATION_MODULE_TO_TASK_MODES: dict[RecentGenerationModuleEnum, tuple[GenerationMode, ...]] = {
RecentGenerationModuleEnum.CHAT_AI: GenerationMode.CHATAPI_ASYNC, RecentGenerationModuleEnum.CHAT_AI: (
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: GenerationMode.HOT_OPENING_REPLICATE, GenerationMode.CHATAPI_ASYNC,
RecentGenerationModuleEnum.SHOT_REPLICATE: GenerationMode.SHOT_REPLICATE, GenerationMode.CHATAPI_CHILD,
),
RecentGenerationModuleEnum.HOT_OPENING_REPLICATE: (GenerationMode.HOT_OPENING_REPLICATE,),
RecentGenerationModuleEnum.SHOT_REPLICATE: (GenerationMode.SHOT_REPLICATE,),
} }
"""最近生成记录模块枚举到 ChatGenerationTask.generation_mode 的映射。""" """最近生成记录模块枚举到 ChatGenerationTask.generation_mode 集合的映射。"""
RECENT_GENERATION_MODULE_TO_TASK_MODE: dict[RecentGenerationModuleEnum, GenerationMode] = {
module: task_modes[0]
for module, task_modes in RECENT_GENERATION_MODULE_TO_TASK_MODES.items()
}
"""兼容旧调用的单一任务模式映射。"""
RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE: dict[str, RecentGenerationModuleEnum] = { RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE: dict[str, RecentGenerationModuleEnum] = {
task_mode.value: module task_mode.value: module
for module, task_mode in RECENT_GENERATION_MODULE_TO_TASK_MODE.items() for module, task_modes in RECENT_GENERATION_MODULE_TO_TASK_MODES.items()
for task_mode in task_modes
} }
"""ChatGenerationTask.generation_mode 字符串值到最近生成记录模块枚举的映射。""" """ChatGenerationTask.generation_mode 字符串值到最近生成记录模块枚举的映射。"""
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from enum import Enum
VIDEO_UPSCALE_CONFIG_KEY = "video_upscale_config"
VIDEO_UPSCALE_CONFIG_DESCRIPTION = "视频生成超分全局配置"
VIDEO_UPSCALE_CONFIG_VERSION = 1
VIDEO_UPSCALE_SOURCE_RETAINED_MARKER = "retained_by_snapshot_config"
class VideoUpscaleProcessorKey(str, Enum):
LOCAL_FFMPEG_CROP_V1 = "local_ffmpeg_crop_v1"
VOLC_LARGE_MODEL_V1 = "volc_large_model_v1"
VOLC_STANDARD_V1 = "volc_standard_v1"
VOLC_PROFESSIONAL_V1 = "volc_professional_v1"
class VideoUpscaleTaskStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
RETRY_WAITING = "retry_waiting"
COMPLETED = "completed"
FAILED = "failed"
class VideoUpscaleStage(str, Enum):
QUEUED = "upscale_queued"
SOURCE_READY = "upscale_source_ready"
LOCAL_PROCESSING = "upscale_local_processing"
REMOTE_SUBMITTING = "upscale_remote_submitting"
REMOTE_POLLING = "upscale_remote_polling"
RESULT_READY = "upscale_result_ready"
RESULT_DOWNLOADING = "upscale_result_downloading"
VALIDATING = "upscale_validating"
GENERATING_COVER = "upscale_generating_cover"
FINALIZING = "upscale_finalizing"
RETRY_WAITING = "upscale_retry_waiting"
COMPLETED = "upscale_completed"
FAILED = "upscale_failed"
class VideoUpscaleInputSourceType(str, Enum):
PROVIDER_REMOTE = "provider_remote"
LOCAL_SIGNED = "local_signed"
class VideoUpscaleProbeStatus(str, Enum):
NOT_CHECKED = "not_checked"
SUCCESS = "success"
FAILED = "failed"
EXPIRED = "expired"
UNPARSABLE = "unparsable"
VIDEO_UPSCALE_RESOLUTIONS = ("480p", "720p", "1080p", "2K", "4K")
VIDEO_UPSCALE_RESOLUTION_SHORT_EDGE = {
"480p": 480,
"720p": 720,
"1080p": 1080,
"2K": 1440,
"4K": 2160,
}
VIDEO_UPSCALE_RESOLUTION_RANK = {
resolution: index for index, resolution in enumerate(VIDEO_UPSCALE_RESOLUTIONS, start=1)
}
def normalize_video_upscale_resolution(value: str | None) -> str:
text = str(value or "").strip()
normalized = text.lower()
aliases = {
"480p": "480p",
"720p": "720p",
"1080p": "1080p",
"2k": "2K",
"4k": "4K",
}
return aliases.get(normalized, text)
def video_upscale_short_edge_pixels(value: str | None) -> int:
normalized = normalize_video_upscale_resolution(value)
try:
return VIDEO_UPSCALE_RESOLUTION_SHORT_EDGE[normalized]
except KeyError as exc:
supported = "".join(VIDEO_UPSCALE_RESOLUTIONS)
raise ValueError(f"不支持的视频超分分辨率: {value},仅支持 {supported}") from exc
LOCAL_PROCESSOR_KEYS = {VideoUpscaleProcessorKey.LOCAL_FFMPEG_CROP_V1.value}
REMOTE_PROCESSOR_KEYS = {
VideoUpscaleProcessorKey.VOLC_LARGE_MODEL_V1.value,
VideoUpscaleProcessorKey.VOLC_STANDARD_V1.value,
VideoUpscaleProcessorKey.VOLC_PROFESSIONAL_V1.value,
}
ALL_PROCESSOR_KEYS = LOCAL_PROCESSOR_KEYS | REMOTE_PROCESSOR_KEYS
+14
View File
@@ -293,6 +293,20 @@ async def _seed_data():
) )
) )
# 文字模型媒体使用 base64 开关
existing_media_format = await db.execute(
select(SystemConfig).where(SystemConfig.key == "llm_media_as_base64").limit(1)
)
if not existing_media_format.scalar_one_or_none():
db.add(
SystemConfig(
id=generate_id(),
key="llm_media_as_base64",
value="true",
description="文字模型请求时图片/视频使用 base64 编码(而非 URL 链接)",
)
)
# Seed credit ratios - model_config_id is kept as a compatible field name, # Seed credit ratios - model_config_id is kept as a compatible field name,
# but now stores the actual engine id: # but now stores the actual engine id:
# - gen_type=video -> video_engines.id # - gen_type=video -> video_engines.id
+2 -1
View File
@@ -21,6 +21,7 @@ from app.models.operation_log import OperationLog
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
from app.models.chat_generation_task_event import ChatGenerationTaskEvent from app.models.chat_generation_task_event import ChatGenerationTaskEvent
from app.models.chat_provider_call_log import ChatProviderCallLog from app.models.chat_provider_call_log import ChatProviderCallLog
from app.models.video_upscale_task import VideoUpscaleTask
from app.models.generated_resource import GeneratedResource from app.models.generated_resource import GeneratedResource
from app.models.upload_resource import UploadResource from app.models.upload_resource import UploadResource
from app.models.user_resource_month_stat import UserResourceMonthStat from app.models.user_resource_month_stat import UserResourceMonthStat
@@ -44,7 +45,7 @@ __all__ = [
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder", "ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio", "TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest", "MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "VideoUpscaleTask",
"GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat", "GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat",
"UserResourceCapacityConfig", "UserResourceCapacityConfig",
"ModuleGenerationProject", "ModuleGenerationStep", "ModuleGenerationProject", "ModuleGenerationStep",
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Index, Integer, String, Text, text from sqlalchemy import Boolean, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
@@ -26,6 +26,19 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
unique=True, unique=True,
postgresql_where=text("deleted_at IS NULL AND idempotency_key IS NOT NULL"), postgresql_where=text("deleted_at IS NULL AND idempotency_key IS NOT NULL"),
), ),
# AI 创作顶层任务在 chatapi_async/chatapi_main 之间切换时,
# 同一个前端幂等键也只能创建一组任务。
Index(
"uq_chat_generation_tasks_user_chat_idempotency",
"user_id",
"idempotency_key",
unique=True,
postgresql_where=text(
"deleted_at IS NULL "
"AND idempotency_key IS NOT NULL "
"AND generation_mode IN ('chatapi_async', 'chatapi_main')"
),
),
# 视频 24 小时降频轮询调度使用。 # 视频 24 小时降频轮询调度使用。
Index( Index(
"idx_chat_generation_tasks_next_poll_at", "idx_chat_generation_tasks_next_poll_at",
@@ -37,6 +50,17 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
"AND next_poll_at IS NOT NULL" "AND next_poll_at IS NOT NULL"
), ),
), ),
Index(
"uq_chat_generation_tasks_parent_index",
"parent_task_id",
"generation_index",
unique=True,
postgresql_where=text("parent_task_id IS NOT NULL AND generation_index IS NOT NULL"),
),
Index("idx_chat_generation_tasks_parent", "parent_task_id"),
Index("idx_chat_generation_tasks_user_mode_created", "user_id", "generation_mode", "created_at"),
CheckConstraint("generation_count BETWEEN 1 AND 5", name="ck_chat_generation_tasks_generation_count"),
CheckConstraint("generation_index IS NULL OR generation_index > 0", name="ck_chat_generation_tasks_generation_index"),
) )
@@ -52,6 +76,9 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
duration: Mapped[int | None] = mapped_column(Integer, nullable=True) duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True) aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True)
resolution: Mapped[str | None] = mapped_column(String(8), nullable=True) resolution: Mapped[str | None] = mapped_column(String(8), nullable=True)
provider_generation_resolution: Mapped[str | None] = mapped_column(String(16), nullable=True)
video_upscale_enabled_snapshot: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
video_upscale_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
image_size: Mapped[str | None] = mapped_column(String(16), nullable=True) image_size: Mapped[str | None] = mapped_column(String(16), nullable=True)
image_proportion: Mapped[str | None] = mapped_column(String(8), nullable=True) image_proportion: Mapped[str | None] = mapped_column(String(8), nullable=True)
image_px: Mapped[str | None] = mapped_column(String(16), nullable=True) image_px: Mapped[str | None] = mapped_column(String(16), nullable=True)
@@ -59,6 +86,17 @@ class ChatGenerationTask(Base, TimestampMixin, SoftDeleteMixin):
status: Mapped[str] = mapped_column(String(32), default="generating", index=True) status: Mapped[str] = mapped_column(String(32), default="generating", index=True)
pipeline_stage: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) pipeline_stage: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
generation_mode: Mapped[str] = mapped_column(String(32), default="chatapi_async", index=True) generation_mode: Mapped[str] = mapped_column(String(32), default="chatapi_async", index=True)
parent_task_id: Mapped[str | None] = mapped_column(
String(32), ForeignKey("chat_generation_tasks.id", ondelete="RESTRICT"), nullable=True
)
generation_count: Mapped[int] = mapped_column(Integer, default=1, server_default="1", nullable=False)
generation_index: Mapped[int | None] = mapped_column(Integer, nullable=True)
# 图片主任务同步调用供应商时的分布式执行租约。
# 防止重复 Celery 消息或恢复任务同时触发多次组图请求。
provider_create_claim_token: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
provider_create_lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
provider_create_started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
media_references: Mapped[str | None] = mapped_column(Text, nullable=True) media_references: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) provider_task_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, Float, Index from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, Float, Index
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin, SoftDeleteMixin from app.models.base import Base, TimestampMixin, SoftDeleteMixin
@@ -22,11 +22,17 @@ class GenerationRecord(Base, TimestampMixin, SoftDeleteMixin):
duration: Mapped[int | None] = mapped_column(Integer, nullable=True) duration: Mapped[int | None] = mapped_column(Integer, nullable=True)
aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True) aspect_ratio: Mapped[str | None] = mapped_column(String(8), nullable=True)
resolution: Mapped[str | None] = mapped_column(String(8), nullable=True) resolution: Mapped[str | None] = mapped_column(String(8), nullable=True)
provider_generation_resolution: Mapped[str | None] = mapped_column(String(16), nullable=True)
video_upscale_enabled_snapshot: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
video_upscale_snapshot_json: Mapped[str | None] = mapped_column(Text, nullable=True)
image_size: Mapped[str | None] = mapped_column(String(8), nullable=True) image_size: Mapped[str | None] = mapped_column(String(8), nullable=True)
image_proportion: Mapped[str | None] = mapped_column(String(8), nullable=True) image_proportion: Mapped[str | None] = mapped_column(String(8), nullable=True)
image_px: Mapped[str | None] = mapped_column(String(10), nullable=True) image_px: Mapped[str | None] = mapped_column(String(10), nullable=True)
status: Mapped[str] = mapped_column(String(32), default="prompt_optimized") status: Mapped[str] = mapped_column(String(32), default="prompt_optimized")
pipeline_stage: Mapped[str | None] = mapped_column(String(48), nullable=True, index=True)
video_url: Mapped[str | None] = mapped_column(String(512), nullable=True) video_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
video_cover_url: Mapped[str | None] = mapped_column(String(512), nullable=True) video_cover_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
image_url: Mapped[str | None] = mapped_column(String(512), nullable=True) image_url: Mapped[str | None] = mapped_column(String(512), nullable=True)
+26 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, Integer, String, Text from sqlalchemy import Boolean, CheckConstraint, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -6,6 +6,11 @@ from app.models.base import Base, TimestampMixin
class ImageEngine(Base, TimestampMixin): class ImageEngine(Base, TimestampMixin):
__tablename__ = "image_engines" __tablename__ = "image_engines"
__table_args__ = (
CheckConstraint("max_generation_count BETWEEN 1 AND 5", name="ck_image_engines_max_generation_count"),
CheckConstraint("multi_image_max_images BETWEEN 1 AND 15", name="ck_image_engines_multi_image_max_images"),
CheckConstraint("max_reference_image_count BETWEEN 0 AND 14", name="ck_image_engines_max_reference_image_count"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True) id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(64), nullable=False) name: Mapped[str] = mapped_column(String(64), nullable=False)
@@ -18,6 +23,26 @@ class ImageEngine(Base, TimestampMixin):
supported_sizes: Mapped[str] = mapped_column(Text, default='{}') supported_sizes: Mapped[str] = mapped_column(Text, default='{}')
default_size: Mapped[str] = mapped_column(String(32), default="2K") default_size: Mapped[str] = mapped_column(String(32), default="2K")
max_image_count: Mapped[int] = mapped_column(Integer, default=0) max_image_count: Mapped[int] = mapped_column(Integer, default=0)
# 管理后台只配置能力开关与数量上限;本次实际生成数量保存在 ChatGenerationTask.generation_count。
multi_generation_enabled: Mapped[bool] = mapped_column(
Boolean,
default=False,
server_default="false",
nullable=False,
)
max_generation_count: Mapped[int] = mapped_column(
Integer,
default=1,
server_default="1",
nullable=False,
)
# 火山组图接口能力约束。多份图片始终只调用一次 sequential_image_generation=auto 接口。
multi_image_max_images: Mapped[int] = mapped_column(Integer, default=15, server_default="15", nullable=False)
max_reference_image_count: Mapped[int] = mapped_column(Integer, default=14, server_default="14", nullable=False)
# 留空表示不向供应商传 output_format;用于兼容不支持该参数的模型。
output_format: Mapped[str] = mapped_column(String(16), default="", server_default="", nullable=False)
generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="") generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
priority: Mapped[int] = mapped_column(Integer, default=0) priority: Mapped[int] = mapped_column(Integer, default=0)
+19 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, Integer, String from sqlalchemy import Boolean, CheckConstraint, Integer, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin from app.models.base import Base, TimestampMixin
@@ -6,6 +6,9 @@ from app.models.base import Base, TimestampMixin
class VideoEngine(Base, TimestampMixin): class VideoEngine(Base, TimestampMixin):
__tablename__ = "video_engines" __tablename__ = "video_engines"
__table_args__ = (
CheckConstraint("max_generation_count BETWEEN 1 AND 5", name="ck_video_engines_max_generation_count"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True) id: Mapped[str] = mapped_column(String(32), primary_key=True)
name: Mapped[str] = mapped_column(String(64), nullable=False) name: Mapped[str] = mapped_column(String(64), nullable=False)
@@ -20,6 +23,21 @@ class VideoEngine(Base, TimestampMixin):
max_image_count: Mapped[int] = mapped_column(Integer, default=2) max_image_count: Mapped[int] = mapped_column(Integer, default=2)
max_video_count: Mapped[int] = mapped_column(Integer, default=0) max_video_count: Mapped[int] = mapped_column(Integer, default=0)
max_audio_count: Mapped[int] = mapped_column(Integer, default=0) max_audio_count: Mapped[int] = mapped_column(Integer, default=0)
# 管理后台只配置能力开关与数量上限;本次实际生成数量保存在 ChatGenerationTask.generation_count。
multi_generation_enabled: Mapped[bool] = mapped_column(
Boolean,
default=False,
server_default="false",
nullable=False,
)
max_generation_count: Mapped[int] = mapped_column(
Integer,
default=1,
server_default="1",
nullable=False,
)
supports_first_last_frame: Mapped[bool] = mapped_column(Boolean, default=False) supports_first_last_frame: Mapped[bool] = mapped_column(Boolean, default=False)
supports_universal_reference: Mapped[bool] = mapped_column(Boolean, default=True) supports_universal_reference: Mapped[bool] = mapped_column(Boolean, default=True)
generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="") generate_url: Mapped[str | None] = mapped_column(String(512), nullable=True, default="")
@@ -0,0 +1,85 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class VideoUpscaleTask(Base, TimestampMixin):
__tablename__ = "video_upscale_tasks"
__table_args__ = (
CheckConstraint(
"(chat_generation_task_id IS NOT NULL AND generation_record_id IS NULL) OR "
"(chat_generation_task_id IS NULL AND generation_record_id IS NOT NULL)",
name="ck_video_upscale_tasks_exactly_one_owner",
),
Index("uq_video_upscale_tasks_chat_task", "chat_generation_task_id", unique=True),
Index("uq_video_upscale_tasks_generation_record", "generation_record_id", unique=True),
Index("idx_video_upscale_tasks_provider_task_id", "provider_task_id"),
Index("idx_video_upscale_tasks_status_next_retry", "status", "next_retry_at"),
Index("idx_video_upscale_tasks_status_lease", "status", "lease_until"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
chat_generation_task_id: Mapped[str | None] = mapped_column(
String(32),
ForeignKey("chat_generation_tasks.id", ondelete="CASCADE"),
nullable=True,
)
generation_record_id: Mapped[str | None] = mapped_column(
String(32),
ForeignKey("generation_records.id", ondelete="CASCADE"),
nullable=True,
)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", server_default="pending")
stage: Mapped[str] = mapped_column(String(48), nullable=False, default="upscale_queued", server_default="upscale_queued")
processor_key: Mapped[str] = mapped_column(String(64), nullable=False)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
manual_retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
source_local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
source_file_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
source_width: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
source_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
source_delete_error: Mapped[str | None] = mapped_column(Text, nullable=True)
source_remote_url: Mapped[str | None] = mapped_column(Text, nullable=True)
source_remote_url_signed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
source_remote_url_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
source_remote_url_last_probe_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
source_remote_url_probe_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
input_source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
input_source_fallback_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
target_width: Mapped[int] = mapped_column(Integer, nullable=False)
target_height: Mapped[int] = mapped_column(Integer, nullable=False)
effective_target_width: Mapped[int | None] = mapped_column(Integer, nullable=True)
effective_target_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
provider_task_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
provider_request_json: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_output_url: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_output_url_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
provider_submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
final_local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
final_resource_url: Mapped[str | None] = mapped_column(Text, nullable=True)
final_file_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
celery_task_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
lease_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
failed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+6
View File
@@ -29,6 +29,12 @@ class ModelConfigOut(ModelConfigCreate):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class SystemConfigCreate(BaseModel):
key: str
value: str
description: str | None = None
class SystemConfigUpdate(BaseModel): class SystemConfigUpdate(BaseModel):
value: str | int | float value: str | int | float
+4
View File
@@ -28,6 +28,10 @@ class ChangePasswordRequest(BaseModel):
new_password: str = Field(..., min_length=6, description="新密码,至少6位") new_password: str = Field(..., min_length=6, description="新密码,至少6位")
class ChangeUsernameRequest(BaseModel):
new_username: str = Field(..., min_length=1, max_length=10, description="新用户名,1-10位")
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
access_token: str access_token: str
token_type: str = "bearer" token_type: str = "bearer"
+2
View File
@@ -50,6 +50,8 @@ class GenerationRecordOut(BaseModel):
image_proportion: str | None = None image_proportion: str | None = None
image_px: str | None = None image_px: str | None = None
status: str status: str
pipeline_stage: str | None = None
video_upscale_enabled: bool = False
video_url: str | None = None video_url: str | None = None
video_cover_url: str | None = None video_cover_url: str | None = None
image_url: str | None = None image_url: str | None = None
+25 -2
View File
@@ -108,6 +108,7 @@ class GenerationAITaskCreate(BaseModel):
} }
], ],
"idempotency_key": "frontend-submit-uuid-001", "idempotency_key": "frontend-submit-uuid-001",
"generation_count": 3,
"image_size": "2K", "image_size": "2K",
"image_proportion": "1:1", "image_proportion": "1:1",
"image_px": "2048x2048", "image_px": "2048x2048",
@@ -122,6 +123,7 @@ class GenerationAITaskCreate(BaseModel):
"engine_id": None, "engine_id": None,
"media_references": None, "media_references": None,
"idempotency_key": "frontend-submit-uuid-002", "idempotency_key": "frontend-submit-uuid-002",
"generation_count": 2,
"image_size": None, "image_size": None,
"image_proportion": None, "image_proportion": None,
"image_px": None, "image_px": None,
@@ -169,11 +171,21 @@ class GenerationAITaskCreate(BaseModel):
max_length=64, max_length=64,
description=( description=(
"幂等键,用于防止前端重复提交、网络重试导致重复创建任务和重复扣费。" "幂等键,用于防止前端重复提交、网络重试导致重复创建任务和重复扣费。"
"同一用户、同一 idempotency_key、同一 generation_mode 下重复请求会返回已有任务" "同一用户、同一 idempotency_key 的 AI 创作顶层请求会返回已有任务,即使客户端再次传入不同生成数量也不会重复创建"
"建议前端每次点击生成时生成 UUID;同一次请求失败重试时复用同一个 UUID。" "建议前端每次点击生成时生成 UUID;同一次请求失败重试时复用同一个 UUID。"
), ),
examples=["frontend-submit-uuid-001"], examples=["frontend-submit-uuid-001"],
) )
generation_count: int = Field(
1,
ge=1,
le=5,
description=(
"客户端本次实际选择的生成数量,默认 1。后端会按当前引擎的多份生成开关、"
"最大生成数量以及图片参考图总量限制再次校验。"
),
examples=[3],
)
# image params # image params
image_size: str | None = Field( image_size: str | None = Field(
@@ -227,6 +239,10 @@ class GenerationAIImageEngineOptionOut(BaseModel):
default_size: str | None = Field(None, description="默认图片分辨率档位,例如 2K") default_size: str | None = Field(None, description="默认图片分辨率档位,例如 2K")
priority: int = Field(0, description="引擎优先级,数值越大越优先") priority: int = Field(0, description="引擎优先级,数值越大越优先")
max_image_count: int = Field(0, description="最大图片数量") max_image_count: int = Field(0, description="最大图片数量")
multi_generation_enabled: bool = Field(False, description="是否允许客户端选择生成多份图片")
max_generation_count: int = Field(1, ge=1, le=5, description="客户端本次最多可选择的图片生成数量")
multi_image_max_images: int = Field(15, ge=1, le=15, description="单次组图输入与输出总图片上限")
max_reference_image_count: int = Field(14, ge=0, le=14, description="允许的最大参考图片数量")
class GenerationAIVideoEngineOptionOut(BaseModel): class GenerationAIVideoEngineOptionOut(BaseModel):
@@ -244,6 +260,8 @@ class GenerationAIVideoEngineOptionOut(BaseModel):
max_image_count: int | None = Field(None, description="最大图片数量") max_image_count: int | None = Field(None, description="最大图片数量")
max_video_count: int | None = Field(None, description="最大视频数量") max_video_count: int | None = Field(None, description="最大视频数量")
max_audio_count: int | None = Field(None, description="最大参考音频数量,0 表示不支持音频参考") max_audio_count: int | None = Field(None, description="最大参考音频数量,0 表示不支持音频参考")
multi_generation_enabled: bool = Field(False, description="是否允许客户端选择生成多个视频")
max_generation_count: int = Field(1, ge=1, le=5, description="客户端本次最多可选择的视频生成数量")
supports_first_last_frame: bool = Field(False, description="是否支持首帧和最后一帧") supports_first_last_frame: bool = Field(False, description="是否支持首帧和最后一帧")
supports_universal_reference: bool = Field(False, description="是否支持通用参考") supports_universal_reference: bool = Field(False, description="是否支持通用参考")
@@ -416,8 +434,12 @@ class GenerationAITaskOut(BaseModel):
gen_type: str = Field(..., description="生成类型:image=图片,video=视频") gen_type: str = Field(..., description="生成类型:image=图片,video=视频")
generation_mode: str | None = Field( generation_mode: str | None = Field(
None, None,
description="生成模式。当前异步Chat生成任务一般为 chatapi_async", description="生成模式chatapi_async=单份任务,chatapi_main=多份主任务,chatapi_child=多份子任务",
) )
parent_task_id: str | None = Field(None, description="多份生成子任务关联的主任务ID")
generation_count: int = Field(1, ge=1, le=5, description="本次实际生成数量快照")
generation_index: int | None = Field(None, ge=1, le=5, description="子任务生成序号,从1开始")
display_status: str | None = Field(None, description="前端展示状态,例如 download_failed、deleted")
pipeline_stage: str | None = Field( pipeline_stage: str | None = Field(
None, None,
description=( description=(
@@ -468,6 +490,7 @@ class GenerationAITaskOut(BaseModel):
error_message: str | None = Field(None, description="错误信息。成功任务一般为 null") error_message: str | None = Field(None, description="错误信息。成功任务一般为 null")
created_at: NaiveDatetimeOptional = Field(None, description="任务创建时间") created_at: NaiveDatetimeOptional = Field(None, description="任务创建时间")
generated_at: NaiveDatetimeOptional = Field(None, description="任务生成完成时间") generated_at: NaiveDatetimeOptional = Field(None, description="任务生成完成时间")
child_items: list["GenerationAITaskOut"] = Field(default_factory=list, description="多份生成子任务列表,按 generation_index 升序")
class GenerationAITaskListOut(BaseModel): class GenerationAITaskListOut(BaseModel):
+44 -1
View File
@@ -1,5 +1,6 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, model_validator
from app.enums.generation_provider import IMAGE_MULTI_OUTPUT_MAX, IMAGE_MULTI_REFERENCE_MAX
from app.schemas.common import NaiveDatetime from app.schemas.common import NaiveDatetime
@@ -13,10 +14,48 @@ class ImageEngineCreate(BaseModel):
supported_sizes: str = Field(default='{}') supported_sizes: str = Field(default='{}')
default_size: str = Field(default="2K", max_length=32) default_size: str = Field(default="2K", max_length=32)
max_image_count: int = Field(default=0) max_image_count: int = Field(default=0)
multi_generation_enabled: bool = Field(
default=False,
description="是否允许客户端选择生成多份图片;关闭时客户端只能选择 1 份",
)
max_generation_count: int = Field(
default=1,
ge=1,
le=5,
description="客户端单次最多可选择的生成数量,范围 1-5",
)
multi_image_max_images: int = Field(
default=IMAGE_MULTI_OUTPUT_MAX,
ge=1,
le=IMAGE_MULTI_OUTPUT_MAX,
description="火山组图接口输入参考图与输出图片总上限",
)
max_reference_image_count: int = Field(
default=IMAGE_MULTI_REFERENCE_MAX,
ge=0,
le=IMAGE_MULTI_REFERENCE_MAX,
description="图片引擎允许的最大参考图片数量",
)
output_format: str = Field(
default="",
max_length=16,
description="供应商输出格式;留空表示不传该参数,用于兼容不支持 output_format 的模型",
)
generate_url: str = Field(default="", max_length=512) generate_url: str = Field(default="", max_length=512)
is_active: bool = True is_active: bool = True
priority: int = 0 priority: int = 0
@model_validator(mode="after")
def validate_multi_generation_capability(self):
if self.max_generation_count > self.multi_image_max_images:
raise ValueError("max_generation_count 不能大于 multi_image_max_images")
normalized_output_format = (self.output_format or "").lower().strip()
if normalized_output_format not in {"", "png", "jpeg"}:
raise ValueError("output_format 仅支持留空、png 或 jpeg")
self.output_format = normalized_output_format
return self
class ImageEngineOut(ImageEngineCreate): class ImageEngineOut(ImageEngineCreate):
id: str id: str
@@ -33,6 +72,10 @@ class ImageEnginePublic(BaseModel):
supported_sizes: dict[str, dict[str, str]] = {} supported_sizes: dict[str, dict[str, str]] = {}
default_size: str = "2K" default_size: str = "2K"
max_image_count: int = 0 max_image_count: int = 0
multi_generation_enabled: bool = False
max_generation_count: int = 1
multi_image_max_images: int = IMAGE_MULTI_OUTPUT_MAX
max_reference_image_count: int = IMAGE_MULTI_REFERENCE_MAX
class ImageEngineListResponse(BaseModel): class ImageEngineListResponse(BaseModel):
+12
View File
@@ -16,6 +16,16 @@ class VideoEngineCreate(BaseModel):
max_image_count: int = Field(default=2) max_image_count: int = Field(default=2)
max_video_count: int = Field(default=0) max_video_count: int = Field(default=0)
max_audio_count: int = Field(default=0, ge=0, le=3, description="最大参考音频数量,0 表示不支持音频参考") max_audio_count: int = Field(default=0, ge=0, le=3, description="最大参考音频数量,0 表示不支持音频参考")
multi_generation_enabled: bool = Field(
default=False,
description="是否允许客户端选择生成多个视频;关闭时客户端只能选择 1 份",
)
max_generation_count: int = Field(
default=1,
ge=1,
le=5,
description="客户端单次最多可选择的生成数量,范围 1-5",
)
supports_first_last_frame: bool = Field(default=False, description="是否支持首尾帧模式") supports_first_last_frame: bool = Field(default=False, description="是否支持首尾帧模式")
supports_universal_reference: bool = Field(default=True, description="是否支持全能参考模式") supports_universal_reference: bool = Field(default=True, description="是否支持全能参考模式")
generate_url: str = Field(default="", max_length=512) generate_url: str = Field(default="", max_length=512)
@@ -41,6 +51,8 @@ class VideoEnginePublic(BaseModel):
max_image_count: int = 2 max_image_count: int = 2
max_video_count: int = 0 max_video_count: int = 0
max_audio_count: int = 0 max_audio_count: int = 0
multi_generation_enabled: bool = False
max_generation_count: int = 1
supports_first_last_frame: bool = False supports_first_last_frame: bool = False
supports_universal_reference: bool = True supports_universal_reference: bool = True
@@ -0,0 +1,59 @@
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from app.enums.video_upscale import normalize_video_upscale_resolution
from app.schemas.common import NaiveDatetimeOptional
class VideoUpscaleResolutionRule(BaseModel):
model_config = ConfigDict(extra="forbid")
target_resolution: str = Field(..., min_length=1, max_length=16)
provider_generation_resolution: str = Field(..., min_length=1, max_length=16)
processor_key: str = Field(..., min_length=1, max_length=64)
enabled: bool = True
@field_validator("target_resolution", "provider_generation_resolution")
@classmethod
def clean_resolution(cls, value: str) -> str:
return normalize_video_upscale_resolution(value)
@field_validator("processor_key")
@classmethod
def clean_processor_key(cls, value: str) -> str:
return str(value).strip()
class VideoUpscaleConfigData(BaseModel):
model_config = ConfigDict(extra="forbid")
enabled: bool = False
version: int = Field(1, ge=1)
delete_source_after_success: bool = True
rules: list[VideoUpscaleResolutionRule] = Field(default_factory=list)
@model_validator(mode="after")
def validate_unique_rules(self) -> "VideoUpscaleConfigData":
seen: set[str] = set()
for rule in self.rules:
if not rule.enabled:
continue
if rule.target_resolution in seen:
raise ValueError(f"客户目标分辨率存在重复启用规则: {rule.target_resolution}")
seen.add(rule.target_resolution)
return self
class VideoUpscaleConfigSaveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
data: VideoUpscaleConfigData
class VideoUpscaleConfigOut(BaseModel):
id: str | None = None
key: str
description: str | None = None
data: VideoUpscaleConfigData
created_at: NaiveDatetimeOptional = None
updated_at: NaiveDatetimeOptional = None
@@ -0,0 +1 @@
"""生成任务领域服务。"""
@@ -0,0 +1 @@
"""AI 创作生成编排服务。"""
@@ -0,0 +1,122 @@
from __future__ import annotations
import json
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.common import MAX_GENERATION_COUNT, MIN_GENERATION_COUNT
from app.enums.generation_provider import IMAGE_MULTI_OUTPUT_MAX, IMAGE_MULTI_REFERENCE_MAX
from app.models.image_engine import ImageEngine
from app.models.video_engine import VideoEngine
IMAGE_DEFAULT_SIZE = "2K"
IMAGE_DEFAULT_PROPORTION = "1:1"
IMAGE_DEFAULT_PX = "2048x2048"
VIDEO_DEFAULT_DURATION = 4
VIDEO_DEFAULT_RATIO = "16:9"
VIDEO_DEFAULT_RESOLUTION = "480p"
def normalize_px(value: str | None) -> str | None:
if not value:
return value
return value.replace("×", "x").replace("X", "x").replace("×x", "x").replace("x×", "x")
def parse_json_list(value: str | None, fallback: list):
try:
parsed = json.loads(value or "")
return parsed if isinstance(parsed, list) else fallback
except Exception:
return fallback
def image_supported_sizes(engine: ImageEngine) -> dict:
try:
data = json.loads(engine.supported_sizes or "{}")
return data if isinstance(data, dict) else {}
except Exception:
return {}
def normalize_generation_count(value: int | None) -> int:
try:
count = int(value or MIN_GENERATION_COUNT)
except (TypeError, ValueError):
count = MIN_GENERATION_COUNT
return min(MAX_GENERATION_COUNT, max(MIN_GENERATION_COUNT, count))
async def get_image_engine(db: AsyncSession, engine_id: str | None) -> ImageEngine:
query = select(ImageEngine).where(ImageEngine.is_active == True)
if engine_id:
query = query.where(ImageEngine.id == engine_id)
else:
query = query.order_by(ImageEngine.priority.desc()).limit(1)
result = await db.execute(query)
engine = result.scalar_one_or_none()
if not engine:
raise HTTPException(status_code=400, detail="没有可用的图片引擎")
return engine
async def get_video_engine(db: AsyncSession, engine_id: str | None) -> VideoEngine:
query = select(VideoEngine).where(VideoEngine.is_active == True)
if engine_id:
query = query.where(VideoEngine.id == engine_id)
else:
query = query.order_by(VideoEngine.priority.desc())
result = await db.execute(query.limit(1))
engine = result.scalar_one_or_none()
if not engine:
raise HTTPException(status_code=400, detail="没有可用的视频引擎")
return engine
def build_image_snapshot(engine: ImageEngine, size: str, proportion: str, px: str) -> dict:
return {
"engine_type": "image",
"id": engine.id,
"name": engine.name,
"provider": engine.provider,
"api_base": engine.api_base,
"api_key_masked": "****" if engine.api_key else "",
"model_name": engine.model_name,
"generate_url": engine.generate_url,
"supported_models": parse_json_list(engine.supported_models, []),
"default_size": engine.default_size,
"multi_generation_enabled": bool(getattr(engine, "multi_generation_enabled", False)),
"max_generation_count": normalize_generation_count(getattr(engine, "max_generation_count", 1)),
"multi_image_max_images": int(getattr(engine, "multi_image_max_images", IMAGE_MULTI_OUTPUT_MAX) or IMAGE_MULTI_OUTPUT_MAX),
"max_reference_image_count": int(getattr(engine, "max_reference_image_count", IMAGE_MULTI_REFERENCE_MAX) or 0),
"output_format": (getattr(engine, "output_format", "") or "").lower().strip(),
"selected_size": size,
"selected_proportion": proportion,
"selected_px": px,
}
def build_video_snapshot(engine: VideoEngine, ratio: str, resolution: str, duration: int) -> dict:
return {
"engine_type": "video",
"id": engine.id,
"name": engine.name,
"provider": engine.provider,
"api_base": engine.api_base,
"api_key_masked": "****" if engine.api_key else "",
"model_name": engine.model_name,
"generate_url": engine.generate_url,
"query_url": engine.query_url,
"supported_ratios": parse_json_list(engine.supported_ratios, []),
"supported_resolutions": parse_json_list(engine.supported_resolutions, []),
"supported_durations": parse_json_list(engine.supported_durations, []),
"max_duration": engine.max_duration,
"max_audio_count": engine.max_audio_count,
"multi_generation_enabled": bool(getattr(engine, "multi_generation_enabled", False)),
"max_generation_count": normalize_generation_count(getattr(engine, "max_generation_count", 1)),
"selected_ratio": ratio,
"selected_resolution": resolution,
"selected_duration": duration,
}
@@ -0,0 +1,532 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.generation_provider import IMAGE_PROVIDER_CLAIM_LEASE_SECONDS
from app.enums.generation_task import (
ChatGenerationPipelineStage,
ChatGenerationTaskEventType,
ChatGenerationTaskStatus,
GenerationMode,
GenerationType,
)
from app.models.chat_generation_task import ChatGenerationTask
from app.services.generation.ai.task_group_service import aggregate_main_task_status, load_children_map
from app.services.generation.log_service import log_task_event
from app.services.generation.provider_service import (
create_image_sync_batch_result_with_engine,
get_runtime_engine,
)
from app.services.generation.refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.image_gen import ImageProviderError
from app.services.operation_log_service import build_exception_detail, log_operation_event
from app.utils.id_gen import generate_id
@dataclass(slots=True)
class ImageBatchClaim:
acquired: bool
main_task_id: str
claim_token: str | None = None
task_snapshot: SimpleNamespace | None = None
runtime_engine: SimpleNamespace | None = None
existing_child_ids: list[str] | None = None
reason: str | None = None
def _now() -> datetime:
return datetime.now(timezone.utc)
def _json(value) -> str | None:
if value is None:
return None
return json.dumps(value, ensure_ascii=False, default=str)
def _aware(value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _lease_alive(task: ChatGenerationTask, now: datetime | None = None) -> bool:
lease_until = _aware(task.provider_create_lease_until)
return bool(task.provider_create_claim_token and lease_until and lease_until > (now or _now()))
def _task_snapshot(main: ChatGenerationTask) -> SimpleNamespace:
return SimpleNamespace(
id=str(main.id),
user_id=str(main.user_id),
generation_mode=str(main.generation_mode),
generation_count=int(main.generation_count or 1),
original_prompt=main.original_prompt,
optimized_prompt=main.optimized_prompt,
media_references=main.media_references,
gen_type=main.gen_type,
duration=main.duration,
aspect_ratio=main.aspect_ratio,
resolution=main.resolution,
image_size=main.image_size,
image_proportion=main.image_proportion,
image_px=main.image_px,
engine_id=main.engine_id,
)
async def _claim_image_main_batch(db: AsyncSession, main_task_id: str) -> ImageBatchClaim:
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id == main_task_id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_MAIN.value,
ChatGenerationTask.gen_type == GenerationType.IMAGE.value,
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
.limit(1)
)
main = result.scalar_one_or_none()
if not main:
await db.rollback()
return ImageBatchClaim(False, main_task_id, reason="main_missing")
children_map = await load_children_map(db, [main.id], include_deleted=True)
existing_children = children_map.get(main.id, [])
if existing_children:
child_ids = [str(child.id) for child in existing_children if child.deleted_at is None]
main.provider_create_claim_token = None
main.provider_create_lease_until = None
await db.commit()
return ImageBatchClaim(False, main_task_id, existing_child_ids=child_ids, reason="already_split")
if main.status != ChatGenerationTaskStatus.GENERATING.value:
status = str(main.status)
await db.rollback()
return ImageBatchClaim(False, main_task_id, reason=f"status_{status}")
now = _now()
if _lease_alive(main, now):
user_id = str(main.user_id)
group_id = str(main.id)
lease_until = main.provider_create_lease_until
await db.rollback()
log_operation_event(
domain="generation_ai_batch",
event_type="IMAGE_MAIN_CLAIM_REJECTED",
event_status="skipped",
source="celery",
user_id=user_id,
group_id=group_id,
task_id=group_id,
detail={"reason": "lease_alive", "lease_until": lease_until},
)
return ImageBatchClaim(False, main_task_id, reason="lease_alive")
deadline = _aware(main.deadline_at)
if deadline and deadline <= now:
main.provider_create_claim_token = None
main.provider_create_lease_until = None
await mark_chat_generation_task_failed_and_refund_once(
db,
task=main,
error_message="图片批量生成任务超时",
pipeline_stage=ChatGenerationPipelineStage.TIMEOUT.value,
)
await db.commit()
return ImageBatchClaim(False, main_task_id, reason="deadline_expired")
claim_token = uuid4().hex
main.provider_create_claim_token = claim_token
main.provider_create_started_at = now
main.provider_create_lease_until = now + timedelta(seconds=IMAGE_PROVIDER_CLAIM_LEASE_SECONDS)
main.pipeline_stage = ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value
runtime_engine = await get_runtime_engine(db, main)
snapshot = _task_snapshot(main)
user_id = str(main.user_id)
generation_count = int(main.generation_count or 1)
lease_until = main.provider_create_lease_until
await db.commit()
log_operation_event(
domain="generation_ai_batch",
event_type="IMAGE_MAIN_CLAIM_ACQUIRED",
event_status="success",
source="celery",
user_id=user_id,
group_id=main_task_id,
task_id=main_task_id,
detail={
"generation_count": generation_count,
"claim_token_suffix": claim_token[-8:],
"lease_until": lease_until,
},
)
return ImageBatchClaim(
True,
main_task_id,
claim_token=claim_token,
task_snapshot=snapshot,
runtime_engine=runtime_engine,
)
def _validate_provider_batch(provider_result: dict, generation_count: int) -> list[dict]:
items = provider_result.get("items") or []
if not isinstance(items, list):
raise RuntimeError("图片供应商返回 items 结构异常")
success_items: list[dict] = []
errors: list[str] = []
for position, item in enumerate(items, start=1):
if not isinstance(item, dict):
errors.append(f"{position}项返回结构无效")
continue
if item.get("error_message") or item.get("error_code"):
errors.append(
f"{position}项: {item.get('error_message') or item.get('error_code') or '生成失败'}"
)
continue
remote_url = str(item.get("remote_result_url") or "").strip()
if not remote_url:
errors.append(f"{position}项: 供应商未返回图片地址")
continue
normalized = dict(item)
normalized["generation_index"] = position
success_items.append(normalized)
generated_images = int(provider_result.get("generated_images") or 0)
if generated_images and generated_images != len(success_items):
errors.append(
f"usage.generated_images={generated_images} 与有效图片数 {len(success_items)} 不一致"
)
if len(items) != generation_count:
errors.append(f"返回条目数应为 {generation_count},实际 {len(items)}")
if len(success_items) != generation_count:
errors.append(f"成功图片数应为 {generation_count},实际 {len(success_items)}")
if errors:
raise RuntimeError("图片组图未全部成功;" + "".join(errors))
return success_items
async def _fail_claimed_main(
db: AsyncSession,
*,
main_task_id: str,
claim_token: str,
error_message: str,
event_type: ChatGenerationTaskEventType,
exception: Exception | None = None,
) -> bool:
try:
await db.rollback()
except Exception:
pass
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id == main_task_id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_MAIN.value,
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
.limit(1)
)
main = result.scalar_one_or_none()
if not main or main.provider_create_claim_token != claim_token:
await db.rollback()
return False
existing_map = await load_children_map(db, [main.id], include_deleted=True)
if existing_map.get(main.id):
# child 已经落库后不再允许图片生成退款。
main.provider_create_claim_token = None
main.provider_create_lease_until = None
await db.commit()
return False
main.provider_create_claim_token = None
main.provider_create_lease_until = None
await mark_chat_generation_task_failed_and_refund_once(
db,
task=main,
error_message=error_message,
pipeline_stage=ChatGenerationPipelineStage.FAILED.value,
)
task_id = str(main.id)
user_id = str(main.user_id)
await db.commit()
await log_task_event(
task_id=task_id,
event_type=event_type.value,
to_status=ChatGenerationTaskStatus.FAILED.value,
to_stage=ChatGenerationPipelineStage.FAILED.value,
message=error_message,
)
log_operation_event(
domain="generation_ai_batch",
event_type=event_type.value,
event_status="failed",
source="celery",
user_id=user_id,
group_id=task_id,
task_id=task_id,
message=error_message,
detail=build_exception_detail(exception) if exception else {"message": error_message},
error=error_message,
)
return True
async def _split_children(
db: AsyncSession,
*,
main_task_id: str,
claim_token: str,
provider_result: dict,
provider_items: list[dict],
) -> list[str]:
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id == main_task_id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_MAIN.value,
ChatGenerationTask.gen_type == GenerationType.IMAGE.value,
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
.limit(1)
)
main = result.scalar_one_or_none()
if not main:
raise RuntimeError("图片主任务不存在或已删除")
if main.provider_create_claim_token != claim_token:
raise RuntimeError("图片主任务执行租约已失效,拒绝拆分子任务")
if main.status != ChatGenerationTaskStatus.GENERATING.value:
raise RuntimeError(f"图片主任务当前状态不允许拆分: {main.status}")
existing_map = await load_children_map(db, [main.id], include_deleted=True)
existing = existing_map.get(main.id, [])
if existing:
main.provider_create_claim_token = None
main.provider_create_lease_until = None
await db.commit()
return [str(child.id) for child in existing if child.deleted_at is None]
expected_count = max(1, int(main.generation_count or 1))
if len(provider_items) != expected_count:
raise RuntimeError(f"图片批量拆分数量不一致,期望 {expected_count},实际 {len(provider_items)}")
log_operation_event(
domain="generation_ai_batch",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_SPLIT_START.value,
event_status="started",
source="celery",
user_id=main.user_id,
group_id=main.id,
task_id=main.id,
detail={"generation_count": expected_count},
)
children: list[ChatGenerationTask] = []
for item in provider_items:
index = int(item.get("generation_index") or 0)
if index < 1 or index > expected_count:
raise RuntimeError(f"无效的图片生成序号: {index}")
child = ChatGenerationTask(
id=generate_id(),
user_id=main.user_id,
original_prompt=main.original_prompt,
optimized_prompt=main.optimized_prompt,
gen_type=main.gen_type,
image_size=main.image_size,
image_proportion=main.image_proportion,
image_px=main.image_px,
status=ChatGenerationTaskStatus.GENERATING.value,
pipeline_stage=ChatGenerationPipelineStage.RESULT_READY.value,
generation_mode=GenerationMode.CHATAPI_CHILD.value,
parent_task_id=main.id,
generation_count=expected_count,
generation_index=index,
media_references=main.media_references,
remote_result_url=item.get("remote_result_url"),
engine_id=main.engine_id,
engine_snapshot_json=main.engine_snapshot_json,
provider_response_json=_json(item.get("response_data") or {}),
# 图片生成计费和 token 都归属于 main;child 只负责下载和资源展示。
credits_cost=0,
image_tokens_used=0,
deadline_at=main.deadline_at,
)
children.append(child)
children.sort(key=lambda child: int(child.generation_index or 0))
db.add_all(children)
main.provider_response_json = _json(provider_result.get("response_data") or provider_result)
main.image_tokens_used = int(provider_result.get("image_tokens") or 0)
main.provider_create_claim_token = None
main.provider_create_lease_until = None
await db.flush()
child_ids = [str(child.id) for child in children]
main_id = str(main.id)
main_user_id = str(main.user_id)
await aggregate_main_task_status(db, parent_task_id=main_id)
await db.commit()
log_operation_event(
domain="generation_ai_batch",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_SPLIT_SUCCESS.value,
event_status="success",
source="celery",
user_id=main_user_id,
group_id=main_id,
task_id=main_id,
detail={"child_task_ids": child_ids},
)
return child_ids
async def _enqueue_child_downloads(db: AsyncSession, child_ids: list[str]) -> dict[str, list[str]]:
if not child_ids:
return {"enqueued": [], "failed": []}
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id.in_(child_ids),
ChatGenerationTask.deleted_at.is_(None),
)
.order_by(ChatGenerationTask.generation_index.asc())
)
children = list(result.scalars().all())
from app.tasks.generation_download_tasks import enqueue_download_task
enqueued: list[str] = []
failed: list[str] = []
for child in children:
if child.status == ChatGenerationTaskStatus.COMPLETED.value:
continue
if child.pipeline_stage in {
ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value,
ChatGenerationPipelineStage.DOWNLOADING.value,
ChatGenerationPipelineStage.RETRY_WAITING.value,
}:
continue
celery_task_id = await enqueue_download_task(db, child, reason="image_batch_split")
if celery_task_id:
enqueued.append(str(child.id))
else:
failed.append(str(child.id))
if children and children[0].parent_task_id:
await aggregate_main_task_status(db, parent_task_id=str(children[0].parent_task_id))
await db.commit()
return {"enqueued": enqueued, "failed": failed}
async def run_image_main_batch(db: AsyncSession, main_task: ChatGenerationTask) -> list[str]:
"""单次同步组图,全部成功后原子拆分 child。
绝不在组图 API 失败后退化为 N 次单图请求。
"""
main_task_id = str(main_task.id)
claim = await _claim_image_main_batch(db, main_task_id)
if claim.existing_child_ids is not None:
await _enqueue_child_downloads(db, claim.existing_child_ids)
return claim.existing_child_ids
if not claim.acquired or not claim.claim_token or not claim.task_snapshot or not claim.runtime_engine:
return []
generation_count = max(1, int(claim.task_snapshot.generation_count or 1))
try:
log_operation_event(
domain="generation_ai_batch",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_PROVIDER_START.value,
event_status="started",
source="celery",
user_id=claim.task_snapshot.user_id,
group_id=main_task_id,
task_id=main_task_id,
detail={"generation_count": generation_count},
)
provider_result = await create_image_sync_batch_result_with_engine(
claim.task_snapshot,
claim.runtime_engine,
generation_count=generation_count,
)
provider_items = _validate_provider_batch(provider_result, generation_count)
log_operation_event(
domain="generation_ai_batch",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_PROVIDER_SUCCESS.value,
event_status="success",
source="celery",
user_id=claim.task_snapshot.user_id,
group_id=main_task_id,
task_id=main_task_id,
detail={
"generation_count": generation_count,
"result_count": len(provider_items),
"image_tokens": int(provider_result.get("image_tokens") or 0),
"single_provider_request": True,
"fallback_to_single_requests": False,
},
)
except Exception as exc:
message = exc.safe_message if isinstance(exc, ImageProviderError) else str(exc)
await _fail_claimed_main(
db,
main_task_id=main_task_id,
claim_token=claim.claim_token,
error_message=message or "图片批量生成失败",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_PROVIDER_FAILED,
exception=exc,
)
return []
try:
child_ids = await _split_children(
db,
main_task_id=main_task_id,
claim_token=claim.claim_token,
provider_result=provider_result,
provider_items=provider_items,
)
except Exception as exc:
await _fail_claimed_main(
db,
main_task_id=main_task_id,
claim_token=claim.claim_token,
error_message=f"图片批量结果拆分失败: {exc}",
event_type=ChatGenerationTaskEventType.IMAGE_BATCH_SPLIT_FAILED,
exception=exc,
)
return []
# child 已提交后,下载投递失败不属于图片生成失败,不退款、不重新请求供应商。
enqueue_result = await _enqueue_child_downloads(db, child_ids)
if enqueue_result["failed"]:
log_operation_event(
domain="generation_ai_batch",
event_type="DOWNLOAD_ENQUEUE_FAILED",
event_status="failed",
source="celery",
user_id=claim.task_snapshot.user_id,
group_id=main_task_id,
task_id=main_task_id,
detail={
"failed_child_task_ids": enqueue_result["failed"],
"enqueued_child_task_ids": enqueue_result["enqueued"],
"provider_regenerated": False,
"generation_refunded": False,
},
)
return child_ids
@@ -1,77 +1,54 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import datetime, timedelta, timezone, date from datetime import datetime, date
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import and_, func, select from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
from app.models.generation_record import GenerationRecord from app.models.generation_record import GenerationRecord
from app.models.project import Project from app.models.project import Project
from app.models.image_engine import ImageEngine from app.models.image_engine import ImageEngine
from app.models.user import User from app.models.user import User
from app.models.video_engine import VideoEngine from app.models.video_engine import VideoEngine
from app.enums.audio_reference import ( from app.enums.generation_task import CHAT_TOP_LEVEL_MODES, GenerationMode
AUDIO_ALLOWED_EXTENSIONS,
AUDIO_MAX_COUNT_LIMIT,
AUDIO_MAX_DURATION_SECONDS,
AUDIO_MAX_TOTAL_DURATION_SECONDS,
AUDIO_MIN_DURATION_SECONDS,
)
from app.enums.generation_history import ( from app.enums.generation_history import (
GenerationHistorySourceEnum, GenerationHistorySourceEnum,
get_generation_history_source_label, get_generation_history_source_label,
get_generation_history_task_mode, get_generation_history_task_modes,
normalize_generation_history_source, normalize_generation_history_source,
HISTORY_DAY_PAGE_SIZE_MAX,
HISTORY_GROUP_ITEM_LIMIT,
) )
from app.schemas.generation_ai import ( from app.schemas.generation_ai import (
GenerationAIEngineGroupOut, GenerationAIEngineGroupOut,
GenerationAIEngineOptionsOut, GenerationAIEngineOptionsOut,
GenerationAIImageEngineOptionOut, GenerationAIImageEngineOptionOut,
GenerationAIRecordHistoryItemOut, GenerationAIRecordHistoryItemOut,
GenerationAITaskCreate,
GenerationAITaskOut, GenerationAITaskOut,
GenerationAIVideoEngineOptionOut, GenerationAIVideoEngineOptionOut,
) )
from app.services.generation_billing_service import (
OWNER_CHAT_GENERATION_TASK,
charge_generation_media_by_params,
)
from app.services.resource_accounting_service import ( from app.services.resource_accounting_service import (
SOURCE_MODEL_CHAT_TASK, SOURCE_MODEL_CHAT_TASK,
SOURCE_MODEL_GENERATION_RECORD, SOURCE_MODEL_GENERATION_RECORD,
batch_get_generated_resource_info_map, batch_get_generated_resource_info_map,
soft_delete_chat_task_resources,
) )
from app.services.resource_signed_url_service import build_resource_signed_url from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.generation_history_meta_service import ( from app.services.generation.history_meta_service import (
GenerationHistoryMeta, GenerationHistoryMeta,
batch_load_generation_history_meta_map, batch_load_generation_history_meta_map,
build_empty_history_meta, build_empty_history_meta,
) )
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.generation.ai.task_group_service import get_display_status, load_children_map
from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls, resolve_private_portrait_references from app.services.generation.ai.engine_service import (
from app.utils.id_gen import generate_id image_supported_sizes,
normalize_generation_count,
IMAGE_DEFAULT_SIZE = "2K" parse_json_list,
IMAGE_DEFAULT_PROPORTION = "1:1" )
IMAGE_DEFAULT_PX = "2048x2048" from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
VIDEO_DEFAULT_DURATION = 4
VIDEO_DEFAULT_RATIO = "16:9"
VIDEO_DEFAULT_RESOLUTION = "480p"
HISTORY_DAY_PAGE_SIZE_MAX = 10
HISTORY_GROUP_ITEM_LIMIT = 10
def normalize_px(value: str | None) -> str | None:
if not value:
return value
return value.replace("×", "x").replace("X", "x").replace("×x", "x").replace("x×", "x")
def _json(data: Any) -> str | None: def _json(data: Any) -> str | None:
if data is None: if data is None:
@@ -88,7 +65,12 @@ def _parse_json(text: str | None):
return None return None
async def _resolve_task_reference_display_map(db: AsyncSession, tasks: list[ChatGenerationTask], *, user_id: str | None = None) -> dict[str, list[dict] | None]: async def _resolve_task_reference_display_map(
db: AsyncSession,
tasks: list[ChatGenerationTask],
*,
user_id: str | None = None,
) -> dict[str, list[dict] | None]:
return await batch_resolve_private_portrait_reference_display_urls( return await batch_resolve_private_portrait_reference_display_urls(
db, db,
{task.id: _parse_json(task.media_references) for task in tasks}, {task.id: _parse_json(task.media_references) for task in tasks},
@@ -96,7 +78,12 @@ async def _resolve_task_reference_display_map(db: AsyncSession, tasks: list[Chat
) )
async def _resolve_generation_record_reference_display_map(db: AsyncSession, records: list[GenerationRecord], *, user_id: str | None = None) -> dict[str, list[dict] | None]: async def _resolve_generation_record_reference_display_map(
db: AsyncSession,
records: list[GenerationRecord],
*,
user_id: str | None = None,
) -> dict[str, list[dict] | None]:
return await batch_resolve_private_portrait_reference_display_urls( return await batch_resolve_private_portrait_reference_display_urls(
db, db,
{record.id: _parse_json(record.media_references) for record in records}, {record.id: _parse_json(record.media_references) for record in records},
@@ -104,90 +91,6 @@ async def _resolve_generation_record_reference_display_map(db: AsyncSession, rec
) )
async def _get_image_engine(db: AsyncSession, engine_id: str | None) -> ImageEngine:
query = select(ImageEngine).where(ImageEngine.is_active == True)
if engine_id:
query = query.where(ImageEngine.id == engine_id)
else:
query = query.order_by(ImageEngine.priority.desc()).limit(1)
result = await db.execute(query)
engine = result.scalar_one_or_none()
if not engine:
raise HTTPException(status_code=400, detail="没有可用的图片引擎")
return engine
async def _get_video_engine(db: AsyncSession, engine_id: str | None) -> VideoEngine:
query = select(VideoEngine).where(VideoEngine.is_active == True)
if engine_id:
query = query.where(VideoEngine.id == engine_id)
else:
query = query.order_by(VideoEngine.priority.desc())
query = query.limit(1)
result = await db.execute(query)
engine = result.scalar_one_or_none()
if not engine:
raise HTTPException(status_code=400, detail="没有可用的视频引擎")
return engine
def _image_supported_sizes(engine: ImageEngine) -> dict:
try:
data = json.loads(engine.supported_sizes or "{}")
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _parse_list(value: str | None, fallback: list):
try:
parsed = json.loads(value or "")
return parsed if isinstance(parsed, list) else fallback
except Exception:
return fallback
def _build_image_snapshot(engine: ImageEngine, size: str, proportion: str, px: str) -> dict:
return {
"engine_type": "image",
"id": engine.id,
"name": engine.name,
"provider": engine.provider,
"api_base": engine.api_base,
"api_key_masked": "****" if engine.api_key else "",
"model_name": engine.model_name,
"generate_url": engine.generate_url,
"supported_models": _parse_list(engine.supported_models, []),
"default_size": engine.default_size,
"selected_size": size,
"selected_proportion": proportion,
"selected_px": px,
}
def _build_video_snapshot(engine: VideoEngine, ratio: str, resolution: str, duration: int) -> dict:
return {
"engine_type": "video",
"id": engine.id,
"name": engine.name,
"provider": engine.provider,
"api_base": engine.api_base,
"api_key_masked": "****" if engine.api_key else "",
"model_name": engine.model_name,
"generate_url": engine.generate_url,
"query_url": engine.query_url,
"supported_ratios": _parse_list(engine.supported_ratios, []),
"supported_resolutions": _parse_list(engine.supported_resolutions, []),
"supported_durations": _parse_list(engine.supported_durations, []),
"max_duration": engine.max_duration,
"max_audio_count": engine.max_audio_count,
"selected_ratio": ratio,
"selected_resolution": resolution,
"selected_duration": duration,
}
async def list_generation_ai_engine_options(db: AsyncSession) -> GenerationAIEngineOptionsOut: async def list_generation_ai_engine_options(db: AsyncSession) -> GenerationAIEngineOptionsOut:
"""获取当前启用的图片/视频生成引擎,供前端创建任务时选择 engine_id。""" """获取当前启用的图片/视频生成引擎,供前端创建任务时选择 engine_id。"""
image_result = await db.execute( image_result = await db.execute(
@@ -207,11 +110,15 @@ async def list_generation_ai_engine_options(db: AsyncSession) -> GenerationAIEng
name=engine.name, name=engine.name,
provider=engine.provider, provider=engine.provider,
model_name=engine.model_name, model_name=engine.model_name,
supported_models=_parse_list(engine.supported_models, []), supported_models=parse_json_list(engine.supported_models, []),
supported_sizes=_image_supported_sizes(engine), supported_sizes=image_supported_sizes(engine),
default_size=engine.default_size, default_size=engine.default_size,
priority=engine.priority or 0, priority=engine.priority or 0,
max_image_count=engine.max_image_count, max_image_count=engine.max_image_count,
multi_generation_enabled=bool(getattr(engine, "multi_generation_enabled", False)),
max_generation_count=normalize_generation_count(getattr(engine, "max_generation_count", 1)),
multi_image_max_images=int(getattr(engine, "multi_image_max_images", 15) or 15),
max_reference_image_count=int(getattr(engine, "max_reference_image_count", 14) or 0),
) )
for engine in image_result.scalars().all() for engine in image_result.scalars().all()
] ]
@@ -221,9 +128,9 @@ async def list_generation_ai_engine_options(db: AsyncSession) -> GenerationAIEng
name=engine.name, name=engine.name,
provider=engine.provider, provider=engine.provider,
model_name=engine.model_name, model_name=engine.model_name,
supported_ratios=_parse_list(engine.supported_ratios, []), supported_ratios=parse_json_list(engine.supported_ratios, []),
supported_resolutions=_parse_list(engine.supported_resolutions, []), supported_resolutions=parse_json_list(engine.supported_resolutions, []),
supported_durations=_parse_list(engine.supported_durations, []), supported_durations=parse_json_list(engine.supported_durations, []),
max_duration=engine.max_duration, max_duration=engine.max_duration,
priority=engine.priority or 0, priority=engine.priority or 0,
max_image_count=engine.max_image_count, max_image_count=engine.max_image_count,
@@ -231,6 +138,8 @@ async def list_generation_ai_engine_options(db: AsyncSession) -> GenerationAIEng
max_audio_count=engine.max_audio_count, max_audio_count=engine.max_audio_count,
supports_first_last_frame=engine.supports_first_last_frame, supports_first_last_frame=engine.supports_first_last_frame,
supports_universal_reference=engine.supports_universal_reference, supports_universal_reference=engine.supports_universal_reference,
multi_generation_enabled=bool(getattr(engine, "multi_generation_enabled", False)),
max_generation_count=normalize_generation_count(getattr(engine, "max_generation_count", 1)),
) )
for engine in video_result.scalars().all() for engine in video_result.scalars().all()
] ]
@@ -240,193 +149,6 @@ async def list_generation_ai_engine_options(db: AsyncSession) -> GenerationAIEng
) )
async def create_async_generation_task(db: AsyncSession, current_user: User, req: GenerationAITaskCreate) -> ChatGenerationTask:
"""Create a project-independent chat generation task.
Important: this writes chat_generation_tasks, not generation_records, so chat
image/video generation no longer needs or validates a project_id.
"""
gen_type = req.gen_type.lower().strip()
if gen_type not in ("image", "video"):
raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video")
if req.idempotency_key:
result = await db.execute(
select(ChatGenerationTask).where(
ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.idempotency_key == req.idempotency_key,
ChatGenerationTask.generation_mode == "chatapi_async",
ChatGenerationTask.deleted_at.is_(None),
).order_by(ChatGenerationTask.created_at.desc()).limit(1)
)
existing = result.scalar_one_or_none()
if existing:
return existing
refs = [r.model_dump(exclude_none=True) for r in (req.media_references or [])]
refs = await resolve_private_portrait_references(
db,
user_id=current_user.id,
media_references=refs,
gen_type=gen_type,
)
now = datetime.now(timezone.utc)
task_id = generate_id()
await assert_user_resource_capacity_available(db, current_user.id)
if gen_type == "image":
if any((r.get("type") or "").lower() == "audio" for r in refs):
raise HTTPException(status_code=400, detail="图片生成不支持音频参考素材")
engine = await _get_image_engine(db, req.engine_id)
sizes = _image_supported_sizes(engine)
size = req.image_size or engine.default_size or IMAGE_DEFAULT_SIZE
proportion = req.image_proportion or IMAGE_DEFAULT_PROPORTION
px = normalize_px(req.image_px)
if sizes:
if size not in sizes:
raise HTTPException(status_code=400, detail=f"图片分辨率档位不支持: {size}")
if proportion not in sizes.get(size, {}):
raise HTTPException(status_code=400, detail=f"图片比例不支持: {proportion}")
px = px or normalize_px((sizes.get(size) or {}).get(proportion))
px = px or IMAGE_DEFAULT_PX
media_billing = await charge_generation_media_by_params(
db,
user_id=current_user.id,
record_id=task_id,
gen_type="image",
image_size=size,
engine_id=engine.id,
project_name="AI生成任务",
description_prefix="AI创作-",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=1,
)
snapshot = _build_image_snapshot(engine, size, proportion, px)
task = ChatGenerationTask(
id=task_id,
user_id=current_user.id,
original_prompt=req.original_prompt,
gen_type="image",
image_size=size,
image_proportion=proportion,
image_px=px,
status="generating",
generation_mode="chatapi_async",
pipeline_stage="queued",
engine_id=engine.id,
engine_snapshot_json=_json(snapshot),
media_references=_json(refs) if refs else None,
credits_cost=round(media_billing.total_charged, 2),
idempotency_key=req.idempotency_key,
deadline_at=now + timedelta(minutes=settings.CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES),
)
else:
engine = await _get_video_engine(db, req.engine_id)
ratio = req.aspect_ratio or VIDEO_DEFAULT_RATIO
resolution = req.resolution or VIDEO_DEFAULT_RESOLUTION
duration = req.duration or VIDEO_DEFAULT_DURATION
ratios = _parse_list(engine.supported_ratios, [])
resolutions = _parse_list(engine.supported_resolutions, [])
durations = _parse_list(engine.supported_durations, [])
if ratios and ratio not in ratios:
raise HTTPException(status_code=400, detail=f"视频比例不支持: {ratio}")
if resolutions and resolution not in resolutions:
raise HTTPException(status_code=400, detail=f"视频分辨率不支持: {resolution}")
if durations and duration not in durations:
raise HTTPException(status_code=400, detail=f"视频时长不支持: {duration}")
if engine.max_duration and duration > engine.max_duration:
raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration}")
input_video_duration = 0.0
if refs:
video_refs = [r for r in refs if (r.get("type") or "").lower() == "video"]
for ref in video_refs:
ref_duration = float(ref.get("duration") or 0)
if ref_duration < 2:
raise HTTPException(status_code=400, detail=f"视频素材最短不能少于 2 秒")
input_video_duration += ref_duration
if input_video_duration > 15:
raise HTTPException(status_code=400, detail=f"所有视频素材总时长不能超过 15 秒,当前 {input_video_duration:.1f}")
audio_refs = [r for r in refs if (r.get("type") or "").lower() == "audio"]
if audio_refs:
max_audio_count = int(engine.max_audio_count or 0)
if max_audio_count <= 0:
raise HTTPException(status_code=400, detail="当前视频引擎不支持音频参考素材")
if max_audio_count > AUDIO_MAX_COUNT_LIMIT:
max_audio_count = AUDIO_MAX_COUNT_LIMIT
if len(audio_refs) > max_audio_count:
raise HTTPException(
status_code=400,
detail=f"参考音频最多可传 {max_audio_count} 段,当前 {len(audio_refs)}",
)
input_audio_duration = 0.0
for ref in audio_refs:
raw_duration = ref.get("duration")
if raw_duration is None:
raw_duration = 0.0
try:
ref_duration = float(raw_duration)
except (TypeError, ValueError):
ref_duration = 0.0
if ref_duration < AUDIO_MIN_DURATION_SECONDS or ref_duration > AUDIO_MAX_DURATION_SECONDS:
raise HTTPException(
status_code=400,
detail=f"单段参考音频时长必须在 {AUDIO_MIN_DURATION_SECONDS}-{AUDIO_MAX_DURATION_SECONDS} 秒之间",
)
input_audio_duration += ref_duration
if input_audio_duration > AUDIO_MAX_TOTAL_DURATION_SECONDS:
raise HTTPException(
status_code=400,
detail=f"所有参考音频总时长不能超过 {AUDIO_MAX_TOTAL_DURATION_SECONDS} 秒,当前 {input_audio_duration:.1f}",
)
media_billing = await charge_generation_media_by_params(
db,
user_id=current_user.id,
record_id=task_id,
gen_type="video",
duration=duration,
resolution=resolution,
engine_id=engine.id,
input_video_duration=input_video_duration if input_video_duration > 0 else None,
project_name="AI生成任务",
description_prefix="AI创作-",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=1,
)
snapshot = _build_video_snapshot(engine, ratio, resolution, duration)
task = ChatGenerationTask(
id=task_id,
user_id=current_user.id,
original_prompt=req.original_prompt,
gen_type="video",
duration=duration,
aspect_ratio=ratio,
resolution=resolution,
image_size=req.image_size or IMAGE_DEFAULT_SIZE,
image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION,
image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX,
status="generating",
generation_mode="chatapi_async",
pipeline_stage="queued",
engine_id=engine.id,
engine_snapshot_json=_json(snapshot),
media_references=_json(refs) if refs else None,
credits_cost=round(media_billing.total_charged, 2),
idempotency_key=req.idempotency_key,
deadline_at=now + timedelta(hours=settings.CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS),
)
db.add(task)
await db.flush()
return task
def _resolve_error_message(error_message: str | None) -> str | None: def _resolve_error_message(error_message: str | None) -> str | None:
"""匹配 ARK_ERRORS 字典,将原始错误码转换为友好提示。 """匹配 ARK_ERRORS 字典,将原始错误码转换为友好提示。
app/api/v1/generation.py _record_to_out 保持一致 app/api/v1/generation.py _record_to_out 保持一致
@@ -479,26 +201,36 @@ def record_to_out(
file_name: str | None = None, file_name: str | None = None,
history_meta: GenerationHistoryMeta | None = None, history_meta: GenerationHistoryMeta | None = None,
media_references: list[dict] | None = None, media_references: list[dict] | None = None,
child_items: list[GenerationAITaskOut] | None = None,
) -> GenerationAITaskOut: ) -> GenerationAITaskOut:
refs = media_references if media_references is not None else _parse_json(task.media_references) refs = media_references if media_references is not None else _parse_json(task.media_references)
snapshot = engine_snapshot_out(_parse_json(task.engine_snapshot_json)) snapshot = engine_snapshot_out(_parse_json(task.engine_snapshot_json))
source = GenerationHistorySourceEnum.CHAT_TASK source = GenerationHistorySourceEnum.CHAT_TASK
try: try:
source = GenerationHistorySourceEnum( if task.generation_mode in {
"chat_task" if task.generation_mode == "chatapi_async" else str(task.generation_mode or "chat_task") GenerationMode.CHATAPI_ASYNC.value,
) GenerationMode.CHATAPI_MAIN.value,
GenerationMode.CHATAPI_CHILD.value,
}:
source = GenerationHistorySourceEnum.CHAT_TASK
else:
source = GenerationHistorySourceEnum(str(task.generation_mode or "chat_task"))
except ValueError: except ValueError:
source = GenerationHistorySourceEnum.CHAT_TASK source = GenerationHistorySourceEnum.CHAT_TASK
meta = history_meta or build_empty_history_meta(source) meta = history_meta or build_empty_history_meta(source)
is_deleted = task.deleted_at is not None
is_main = task.generation_mode == GenerationMode.CHATAPI_MAIN.value
hide_resource = is_deleted or is_main
return GenerationAITaskOut( return GenerationAITaskOut(
id=task.id, id=task.id,
user_id=task.user_id if is_admin else None, user_id=task.user_id if is_admin else None,
user_name=getattr(task, "username", None) if is_admin else None, user_name=getattr(task, "username", None) if is_admin else None,
project_id=None, project_id=None,
generated_resource_id=generated_resource_id, generated_resource_id=None if hide_resource else generated_resource_id,
file_name=file_name, file_name=None if hide_resource else file_name,
history_source=meta.get("history_source"), history_source=meta.get("history_source"),
history_source_label=meta.get("history_source_label"), history_source_label=meta.get("history_source_label"),
module_project_id=meta.get("module_project_id"), module_project_id=meta.get("module_project_id"),
@@ -515,10 +247,14 @@ def record_to_out(
shot_segment_label=meta.get("shot_segment_label"), shot_segment_label=meta.get("shot_segment_label"),
gen_type=task.gen_type, gen_type=task.gen_type,
generation_mode=task.generation_mode, generation_mode=task.generation_mode,
parent_task_id=task.parent_task_id,
generation_count=max(1, min(5, int(task.generation_count or 1))),
generation_index=task.generation_index,
display_status=get_display_status(task),
pipeline_stage=task.pipeline_stage, pipeline_stage=task.pipeline_stage,
status=task.status, status=task.status,
original_prompt=task.original_prompt, original_prompt=task.original_prompt,
# optimized_prompt=task.optimized_prompt, optimized_prompt=task.optimized_prompt,
duration=task.duration, duration=task.duration,
aspect_ratio=task.aspect_ratio, aspect_ratio=task.aspect_ratio,
resolution=task.resolution, resolution=task.resolution,
@@ -528,10 +264,9 @@ def record_to_out(
media_references=refs, media_references=refs,
provider_task_id=task.provider_task_id, provider_task_id=task.provider_task_id,
seedance_task_id=task.seedance_task_id, seedance_task_id=task.seedance_task_id,
# remote_result_url=task.remote_result_url, image_url="" if hide_resource else (build_resource_signed_url(task.image_url) if task.image_url else ""),
image_url=build_resource_signed_url(task.image_url) if task.image_url else "", video_url="" if hide_resource else (build_resource_signed_url(task.video_url) if task.video_url else ""),
video_url=build_resource_signed_url(task.video_url) if task.video_url else "", video_cover_url="" if hide_resource else (build_resource_signed_url(task.video_cover_url) if task.video_cover_url else ""),
video_cover_url=build_resource_signed_url(task.video_cover_url) if task.video_cover_url else "",
engine_id=task.engine_id, engine_id=task.engine_id,
engine_snapshot=snapshot, engine_snapshot=snapshot,
credits_cost=task.credits_cost or 0.0, credits_cost=task.credits_cost or 0.0,
@@ -541,33 +276,90 @@ def record_to_out(
video_tokens_used=task.video_tokens_used or 0, video_tokens_used=task.video_tokens_used or 0,
retry_count=task.retry_count or 0, retry_count=task.retry_count or 0,
poll_count=task.poll_count or 0, poll_count=task.poll_count or 0,
error_message=_resolve_error_message(task.error_message), error_message=task.error_message if is_main else _resolve_error_message(task.error_message),
created_at=task.created_at, created_at=task.created_at,
generated_at=task.generated_at, generated_at=task.generated_at,
child_items=child_items or [],
) )
def engine_snapshot_out(snapshot: dict) -> dict: def engine_snapshot_out(snapshot: dict) -> dict:
""" """从完整引擎快照中过滤前端允许展示的字段。"""
从完整的 engine_snapshot 中过滤出需要返回的字段
"""
if not snapshot: if not snapshot:
return {} return {}
keys = (
"engine_type", "id", "name", "provider", "model_name",
"supported_models", "default_size", "selected_size",
"selected_proportion", "selected_px", "supported_ratios",
"supported_resolutions", "supported_durations", "max_duration",
"max_audio_count", "selected_ratio", "selected_resolution",
"selected_duration", "generation_count", "multi_generation_enabled",
"max_generation_count", "multi_image_max_images", "max_reference_image_count", "output_format",
)
result = {key: snapshot.get(key) for key in keys if key in snapshot}
result.setdefault("generation_count", 1)
return result
return {
"engine_type": snapshot.get("engine_type"), async def build_task_out_list(
"id": snapshot.get("id"), db: AsyncSession,
"name": snapshot.get("name"), tasks: list[ChatGenerationTask],
"provider": snapshot.get("provider"), *,
# "api_base": snapshot.get("api_base"), is_admin: bool = False,
# "api_key_masked": snapshot.get("api_key_masked"), viewer_user_id: str | None = None,
"model_name": snapshot.get("model_name"), ) -> list[GenerationAITaskOut]:
# "generate_url": snapshot.get("generate_url"), """批量回填主任务子项、资源账本和参考素材,避免列表 N+1。"""
"supported_models": snapshot.get("supported_models", []), if not tasks:
"default_size": snapshot.get("default_size"), return []
"selected_size": snapshot.get("selected_size"), parent_ids = [
"selected_proportion": snapshot.get("selected_proportion"), task.id for task in tasks
"selected_px": snapshot.get("selected_px") if task.generation_mode == GenerationMode.CHATAPI_MAIN.value
} ]
children_map = await load_children_map(db, parent_ids, include_deleted=True)
children = [child for items in children_map.values() for child in items]
resource_task_ids = [
task.id for task in [*tasks, *children]
if task.generation_mode != GenerationMode.CHATAPI_MAIN.value and task.deleted_at is None
]
resource_info_map = await batch_get_generated_resource_info_map(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=resource_task_ids,
)
reference_display_map = await _resolve_task_reference_display_map(
db,
tasks,
user_id=viewer_user_id,
)
output: list[GenerationAITaskOut] = []
for task in tasks:
refs = reference_display_map.get(task.id)
child_out: list[GenerationAITaskOut] = []
for child in children_map.get(task.id, []):
if is_admin:
child.username = getattr(task, "username", None)
resource = resource_info_map.get(child.id, {})
child_out.append(
record_to_out(
child,
is_admin=is_admin,
generated_resource_id=resource.get("resource_id"),
file_name=resource.get("file_name"),
media_references=refs,
)
)
resource = resource_info_map.get(task.id, {})
output.append(
record_to_out(
task,
is_admin=is_admin,
generated_resource_id=resource.get("resource_id"),
file_name=resource.get("file_name"),
media_references=refs,
child_items=child_out,
)
)
return output
async def list_async_generation_tasks( async def list_async_generation_tasks(
db: AsyncSession, db: AsyncSession,
@@ -594,7 +386,7 @@ async def list_async_generation_tasks(
query = select(ChatGenerationTask) query = select(ChatGenerationTask)
query = query.where( query = query.where(
ChatGenerationTask.generation_mode == "chatapi_async", ChatGenerationTask.generation_mode.in_(list(CHAT_TOP_LEVEL_MODES)),
ChatGenerationTask.deleted_at.is_(None), ChatGenerationTask.deleted_at.is_(None),
) )
@@ -623,7 +415,7 @@ async def list_async_generation_tasks(
total = (await db.execute(count_query)).scalar_one() total = (await db.execute(count_query)).scalar_one()
result = await db.execute( result = await db.execute(
query.order_by(ChatGenerationTask.created_at.desc()) query.order_by(ChatGenerationTask.created_at.desc(), ChatGenerationTask.id.desc())
.offset((page - 1) * page_size) .offset((page - 1) * page_size)
.limit(page_size) .limit(page_size)
) )
@@ -677,12 +469,12 @@ def _parse_history_date(value: str) -> date:
def _history_base_filters(user_id: str, gen_type: str, source: GenerationHistorySourceEnum): def _history_base_filters(user_id: str, gen_type: str, source: GenerationHistorySourceEnum):
task_mode = get_generation_history_task_mode(source) task_modes = get_generation_history_task_modes(source)
if not task_mode: if not task_modes:
raise HTTPException(status_code=400, detail="history_source 不支持查询 ChatGenerationTask 历史") raise HTTPException(status_code=400, detail="history_source 不支持查询 ChatGenerationTask 历史")
return [ return [
ChatGenerationTask.user_id == user_id, ChatGenerationTask.user_id == user_id,
ChatGenerationTask.generation_mode == task_mode.value, ChatGenerationTask.generation_mode.in_([mode.value for mode in task_modes]),
ChatGenerationTask.deleted_at.is_(None), ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.status == "completed", ChatGenerationTask.status == "completed",
ChatGenerationTask.gen_type == gen_type, ChatGenerationTask.gen_type == gen_type,
@@ -1193,13 +985,3 @@ async def list_generation_history_day_items(
], ],
} }
async def soft_delete_chat_generation_task(
db: AsyncSession,
*,
task: ChatGenerationTask,
deleted_at: datetime | None = None,
) -> int:
"""软删 ChatGenerationTask 并联动软删资源账本,返回释放的 active 空间字节数。"""
deleted_at = deleted_at or datetime.now(timezone.utc)
task.deleted_at = deleted_at
return await soft_delete_chat_task_resources(db, task.id, deleted_at=deleted_at)
@@ -0,0 +1,607 @@
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.audio_reference import (
AUDIO_MAX_COUNT_LIMIT,
AUDIO_MAX_DURATION_SECONDS,
AUDIO_MAX_TOTAL_DURATION_SECONDS,
AUDIO_MIN_DURATION_SECONDS,
)
from app.enums.generation_task import CHAT_TOP_LEVEL_MODES, GenerationMode, GenerationType
from app.models.chat_generation_task import ChatGenerationTask
from app.models.user import User
from app.schemas.generation_ai import GenerationAITaskCreate
from app.services.generation.ai.engine_service import (
IMAGE_DEFAULT_PROPORTION,
IMAGE_DEFAULT_PX,
IMAGE_DEFAULT_SIZE,
VIDEO_DEFAULT_DURATION,
VIDEO_DEFAULT_RATIO,
VIDEO_DEFAULT_RESOLUTION,
build_image_snapshot,
build_video_snapshot,
get_image_engine,
get_video_engine,
image_supported_sizes,
normalize_generation_count,
normalize_px,
parse_json_list,
)
from app.services.generation.billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params
from app.services.operation_log_service import log_operation_event
from app.services.private_portrait.reference_resolver import resolve_private_portrait_references
from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
from app.utils.id_gen import generate_id
@dataclass(slots=True)
class GenerationTaskCreateResult:
top_level_task_id: str
enqueue_task_ids: list[str] = field(default_factory=list)
child_task_ids: list[str] = field(default_factory=list)
generation_count: int = 1
gen_type: str = GenerationType.IMAGE.value
created: bool = True
def _json(data: Any) -> str | None:
if data is None:
return None
return json.dumps(data, ensure_ascii=False, default=str)
async def find_existing_top_level_task(
db: AsyncSession,
*,
user_id: str,
idempotency_key: str | None,
) -> ChatGenerationTask | None:
if not idempotency_key:
return None
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.user_id == user_id,
ChatGenerationTask.idempotency_key == idempotency_key,
ChatGenerationTask.generation_mode.in_(list(CHAT_TOP_LEVEL_MODES)),
ChatGenerationTask.deleted_at.is_(None),
)
.order_by(ChatGenerationTask.created_at.desc())
.limit(1)
)
return result.scalar_one_or_none()
def _validate_video_references(refs: list[dict], *, max_audio_count: int) -> float:
input_video_duration = 0.0
for ref in refs:
if (ref.get("type") or "").lower() != GenerationType.VIDEO.value:
continue
try:
ref_duration = float(ref.get("duration") or 0)
except (TypeError, ValueError):
ref_duration = 0.0
if ref_duration < 2:
raise HTTPException(status_code=400, detail="视频素材最短不能少于 2 秒")
input_video_duration += ref_duration
if input_video_duration > 15:
raise HTTPException(status_code=400, detail=f"所有视频素材总时长不能超过 15 秒,当前 {input_video_duration:.1f}")
audio_refs = [ref for ref in refs if (ref.get("type") or "").lower() == "audio"]
if audio_refs:
allowed_count = min(AUDIO_MAX_COUNT_LIMIT, max(0, int(max_audio_count or 0)))
if allowed_count <= 0:
raise HTTPException(status_code=400, detail="当前视频引擎不支持音频参考素材")
if len(audio_refs) > allowed_count:
raise HTTPException(status_code=400, detail=f"参考音频最多可传 {allowed_count} 段,当前 {len(audio_refs)}")
input_audio_duration = 0.0
for ref in audio_refs:
try:
ref_duration = float(ref.get("duration") or 0)
except (TypeError, ValueError):
ref_duration = 0.0
if ref_duration < AUDIO_MIN_DURATION_SECONDS or ref_duration > AUDIO_MAX_DURATION_SECONDS:
raise HTTPException(
status_code=400,
detail=f"单段参考音频时长必须在 {AUDIO_MIN_DURATION_SECONDS}-{AUDIO_MAX_DURATION_SECONDS} 秒之间",
)
input_audio_duration += ref_duration
if input_audio_duration > AUDIO_MAX_TOTAL_DURATION_SECONDS:
raise HTTPException(
status_code=400,
detail=f"所有参考音频总时长不能超过 {AUDIO_MAX_TOTAL_DURATION_SECONDS} 秒,当前 {input_audio_duration:.1f}",
)
return input_video_duration
def _base_task_kwargs(
*,
task_id: str,
user_id: str,
req: GenerationAITaskCreate,
gen_type: str,
generation_mode: str,
generation_count: int,
engine_id: str,
engine_snapshot_json: str,
media_references_json: str | None,
deadline_at: datetime,
parent_task_id: str | None = None,
generation_index: int | None = None,
credits_cost: float = 0.0,
idempotency_key: str | None = None,
) -> dict[str, Any]:
return {
"id": task_id,
"user_id": user_id,
"original_prompt": req.original_prompt,
"gen_type": gen_type,
"status": "generating",
"generation_mode": generation_mode,
"pipeline_stage": "queued",
"parent_task_id": parent_task_id,
"generation_count": generation_count,
"generation_index": generation_index,
"engine_id": engine_id,
"engine_snapshot_json": engine_snapshot_json,
"media_references": media_references_json,
"credits_cost": round(float(credits_cost or 0), 2),
"idempotency_key": idempotency_key,
"deadline_at": deadline_at,
}
async def create_generation_task_group(
db: AsyncSession,
current_user: User,
req: GenerationAITaskCreate,
) -> GenerationTaskCreateResult:
"""创建单份 chatapi_async 或多份 chatapi_main/chatapi_child 任务组。
本函数只 flush,不主动 commit。调用方提交成功后才能投递 Celery。
"""
gen_type = (req.gen_type or "").lower().strip()
if gen_type not in (GenerationType.IMAGE.value, GenerationType.VIDEO.value):
raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video")
existing = await find_existing_top_level_task(
db,
user_id=current_user.id,
idempotency_key=req.idempotency_key,
)
if existing:
return GenerationTaskCreateResult(
top_level_task_id=existing.id,
generation_count=int(existing.generation_count or 1),
gen_type=existing.gen_type,
created=False,
)
refs = [item.model_dump(exclude_none=True) for item in (req.media_references or [])]
refs = await resolve_private_portrait_references(
db,
user_id=current_user.id,
media_references=refs,
gen_type=gen_type,
)
media_references_json = _json(refs) if refs else None
await assert_user_resource_capacity_available(db, current_user.id)
now = datetime.now(timezone.utc)
main_id = generate_id()
child_ids: list[str] = []
enqueue_ids: list[str] = []
total_billed_credits = 0.0
log_operation_event(
domain="generation_ai_batch",
event_type="BATCH_CREATE_START",
event_status="started",
source="service",
user_id=current_user.id,
group_id=main_id,
detail={
"gen_type": gen_type,
"requested_generation_count": normalize_generation_count(req.generation_count),
"idempotency_key_present": bool(req.idempotency_key),
},
)
if gen_type == GenerationType.IMAGE.value:
if any((ref.get("type") or "").lower() == "audio" for ref in refs):
raise HTTPException(status_code=400, detail="图片生成不支持音频参考素材")
engine = await get_image_engine(db, req.engine_id)
generation_count = normalize_generation_count(req.generation_count)
multi_generation_enabled = bool(getattr(engine, "multi_generation_enabled", False))
max_generation_count = normalize_generation_count(getattr(engine, "max_generation_count", 1))
if generation_count > 1 and not multi_generation_enabled:
raise HTTPException(status_code=400, detail="当前图片引擎未开启多份生成,本次生成数量只能为 1")
if generation_count > max_generation_count:
raise HTTPException(
status_code=400,
detail=f"当前图片引擎本次最多允许生成 {max_generation_count}",
)
reference_image_count = sum(
1 for ref in refs if (ref.get("type") or "").lower() == GenerationType.IMAGE.value
)
max_reference_count = max(0, int(getattr(engine, "max_reference_image_count", 14) or 0))
multi_image_max_images = max(1, int(getattr(engine, "multi_image_max_images", 15) or 15))
if reference_image_count > max_reference_count:
raise HTTPException(
status_code=400,
detail=f"当前图片引擎最多支持 {max_reference_count} 张参考图,当前 {reference_image_count}",
)
if generation_count > 1 and reference_image_count + generation_count > multi_image_max_images:
raise HTTPException(
status_code=400,
detail=(
f"参考图数量与生成数量合计不能超过 {multi_image_max_images} 张,"
f"当前参考图 {reference_image_count} 张、生成 {generation_count}"
),
)
sizes = image_supported_sizes(engine)
size = req.image_size or engine.default_size or IMAGE_DEFAULT_SIZE
proportion = req.image_proportion or IMAGE_DEFAULT_PROPORTION
px = normalize_px(req.image_px)
if sizes:
if size not in sizes:
raise HTTPException(status_code=400, detail=f"图片分辨率档位不支持: {size}")
if proportion not in sizes.get(size, {}):
raise HTTPException(status_code=400, detail=f"图片比例不支持: {proportion}")
px = px or normalize_px((sizes.get(size) or {}).get(proportion))
px = px or IMAGE_DEFAULT_PX
mode = GenerationMode.CHATAPI_ASYNC.value if generation_count == 1 else GenerationMode.CHATAPI_MAIN.value
billing = await charge_generation_media_by_params(
db,
user_id=current_user.id,
record_id=main_id,
gen_type=GenerationType.IMAGE.value,
image_size=size,
engine_id=engine.id,
project_name="AI生成任务",
description_prefix="AI创作-",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=1,
quantity=generation_count,
)
image_snapshot = build_image_snapshot(engine, size, proportion, px)
image_snapshot["generation_count"] = generation_count
snapshot_json = _json(image_snapshot) or "{}"
total_billed_credits = round(float(billing.total_charged or 0), 2)
task = ChatGenerationTask(
**_base_task_kwargs(
task_id=main_id,
user_id=current_user.id,
req=req,
gen_type=GenerationType.IMAGE.value,
generation_mode=mode,
generation_count=generation_count,
engine_id=engine.id,
engine_snapshot_json=snapshot_json,
media_references_json=media_references_json,
deadline_at=now + timedelta(minutes=settings.CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES),
credits_cost=billing.total_charged,
idempotency_key=req.idempotency_key,
),
image_size=size,
image_proportion=proportion,
image_px=px,
)
db.add(task)
enqueue_ids.append(task.id)
else:
engine = await get_video_engine(db, req.engine_id)
generation_count = normalize_generation_count(req.generation_count)
multi_generation_enabled = bool(getattr(engine, "multi_generation_enabled", False))
max_generation_count = normalize_generation_count(getattr(engine, "max_generation_count", 1))
if generation_count > 1 and not multi_generation_enabled:
raise HTTPException(status_code=400, detail="当前视频引擎未开启多份生成,本次生成数量只能为 1")
if generation_count > max_generation_count:
raise HTTPException(
status_code=400,
detail=f"当前视频引擎本次最多允许生成 {max_generation_count}",
)
ratio = req.aspect_ratio or VIDEO_DEFAULT_RATIO
resolution = req.resolution or VIDEO_DEFAULT_RESOLUTION
duration = req.duration or VIDEO_DEFAULT_DURATION
ratios = parse_json_list(engine.supported_ratios, [])
resolutions = parse_json_list(engine.supported_resolutions, [])
durations = parse_json_list(engine.supported_durations, [])
if ratios and ratio not in ratios:
raise HTTPException(status_code=400, detail=f"视频比例不支持: {ratio}")
if resolutions and resolution not in resolutions:
raise HTTPException(status_code=400, detail=f"视频分辨率不支持: {resolution}")
if durations and duration not in durations:
raise HTTPException(status_code=400, detail=f"视频时长不支持: {duration}")
if engine.max_duration and duration > engine.max_duration:
raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration}")
input_video_duration = _validate_video_references(refs, max_audio_count=engine.max_audio_count)
provider_generation_resolution, upscale_enabled_snapshot, upscale_snapshot_json = await build_video_upscale_snapshot(
db,
target_resolution=resolution,
aspect_ratio=ratio,
supported_provider_resolutions=resolutions,
)
video_snapshot = build_video_snapshot(engine, ratio, resolution, duration)
video_snapshot["provider_generation_resolution"] = provider_generation_resolution
video_snapshot["video_upscale_enabled_snapshot"] = upscale_enabled_snapshot
video_snapshot["generation_count"] = generation_count
snapshot_json = _json(video_snapshot) or "{}"
deadline_at = now + timedelta(hours=settings.CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS)
if generation_count == 1:
billing = await charge_generation_media_by_params(
db,
user_id=current_user.id,
record_id=main_id,
gen_type=GenerationType.VIDEO.value,
duration=duration,
resolution=resolution,
engine_id=engine.id,
input_video_duration=input_video_duration if input_video_duration > 0 else None,
project_name="AI生成任务",
description_prefix="AI创作-",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=1,
)
total_billed_credits = round(float(billing.total_charged or 0), 2)
task = ChatGenerationTask(
**_base_task_kwargs(
task_id=main_id,
user_id=current_user.id,
req=req,
gen_type=GenerationType.VIDEO.value,
generation_mode=GenerationMode.CHATAPI_ASYNC.value,
generation_count=1,
engine_id=engine.id,
engine_snapshot_json=snapshot_json,
media_references_json=media_references_json,
deadline_at=deadline_at,
credits_cost=billing.total_charged,
idempotency_key=req.idempotency_key,
),
duration=duration,
aspect_ratio=ratio,
resolution=resolution,
provider_generation_resolution=provider_generation_resolution,
video_upscale_enabled_snapshot=upscale_enabled_snapshot,
video_upscale_snapshot_json=upscale_snapshot_json,
image_size=req.image_size or IMAGE_DEFAULT_SIZE,
image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION,
image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX,
)
db.add(task)
enqueue_ids.append(task.id)
else:
main_task = ChatGenerationTask(
**_base_task_kwargs(
task_id=main_id,
user_id=current_user.id,
req=req,
gen_type=GenerationType.VIDEO.value,
generation_mode=GenerationMode.CHATAPI_MAIN.value,
generation_count=generation_count,
engine_id=engine.id,
engine_snapshot_json=snapshot_json,
media_references_json=media_references_json,
deadline_at=deadline_at,
idempotency_key=req.idempotency_key,
),
duration=duration,
aspect_ratio=ratio,
resolution=resolution,
provider_generation_resolution=provider_generation_resolution,
video_upscale_enabled_snapshot=upscale_enabled_snapshot,
video_upscale_snapshot_json=upscale_snapshot_json,
image_size=req.image_size or IMAGE_DEFAULT_SIZE,
image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION,
image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX,
)
db.add(main_task)
await db.flush()
total_credits = 0.0
children: list[ChatGenerationTask] = []
for generation_index in range(1, generation_count + 1):
child_id = generate_id()
billing = await charge_generation_media_by_params(
db,
user_id=current_user.id,
record_id=child_id,
gen_type=GenerationType.VIDEO.value,
duration=duration,
resolution=resolution,
engine_id=engine.id,
input_video_duration=input_video_duration if input_video_duration > 0 else None,
project_name="AI生成任务",
description_prefix=f"AI创作-第{generation_index}份-",
owner_type=OWNER_CHAT_GENERATION_TASK,
attempt_no=1,
)
child = ChatGenerationTask(
**_base_task_kwargs(
task_id=child_id,
user_id=current_user.id,
req=req,
gen_type=GenerationType.VIDEO.value,
generation_mode=GenerationMode.CHATAPI_CHILD.value,
generation_count=generation_count,
generation_index=generation_index,
parent_task_id=main_id,
engine_id=engine.id,
engine_snapshot_json=snapshot_json,
media_references_json=media_references_json,
deadline_at=deadline_at,
credits_cost=billing.total_charged,
),
duration=duration,
aspect_ratio=ratio,
resolution=resolution,
provider_generation_resolution=provider_generation_resolution,
video_upscale_enabled_snapshot=upscale_enabled_snapshot,
video_upscale_snapshot_json=upscale_snapshot_json,
image_size=req.image_size or IMAGE_DEFAULT_SIZE,
image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION,
image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX,
)
children.append(child)
child_ids.append(child_id)
enqueue_ids.append(child_id)
total_credits = round(total_credits + billing.total_charged, 2)
db.add_all(children)
main_task.credits_cost = total_credits
total_billed_credits = total_credits
await db.flush()
log_operation_event(
domain="generation_ai_batch",
event_type="BATCH_BILLING_SUCCESS",
event_status="success",
source="service",
user_id=current_user.id,
group_id=main_id,
detail={
"gen_type": gen_type,
"generation_count": generation_count,
"total_billed_credits": total_billed_credits,
},
)
log_operation_event(
domain="generation_ai_batch",
event_type="BATCH_CHILDREN_CREATED" if child_ids else "BATCH_MAIN_CREATED",
event_status="success",
source="service",
user_id=current_user.id,
group_id=main_id,
detail={
"gen_type": gen_type,
"generation_count": generation_count,
"child_task_ids": child_ids,
"enqueue_task_ids": enqueue_ids,
"video_upscale_enabled_snapshot": bool(locals().get("upscale_enabled_snapshot", False)),
"provider_generation_resolution": locals().get("provider_generation_resolution"),
},
)
return GenerationTaskCreateResult(
top_level_task_id=main_id,
enqueue_task_ids=enqueue_ids,
child_task_ids=child_ids,
generation_count=generation_count,
gen_type=gen_type,
created=True,
)
async def enqueue_created_generation_tasks(
db: AsyncSession,
*,
task_ids: list[str],
) -> list[str]:
"""在业务事务提交后投递任务;返回投递失败的任务ID。
投递失败会在补偿事务中将对应任务置为失败并幂等退款,视频子任务
同时触发主任务状态汇总。调用方不应在初始事务提交前调用本函数。
"""
from app.services.generation.ai.task_group_service import aggregate_parent_for_child
from app.services.generation.log_service import log_task_event
from app.services.generation.refund_service import mark_chat_generation_task_failed_and_refund_once
from app.tasks.generation_create_tasks import chatapi_create_generation_task
normalized_ids = list(dict.fromkeys(str(item) for item in task_ids if item))
meta_result = await db.execute(
select(
ChatGenerationTask.id,
ChatGenerationTask.user_id,
ChatGenerationTask.parent_task_id,
ChatGenerationTask.generation_index,
).where(ChatGenerationTask.id.in_(normalized_ids))
) if normalized_ids else None
task_meta = {
str(row.id): {
"user_id": str(row.user_id),
"parent_task_id": str(row.parent_task_id) if row.parent_task_id else None,
"generation_index": row.generation_index,
}
for row in (meta_result.all() if meta_result is not None else [])
}
failed_ids: list[str] = []
for task_id in normalized_ids:
meta = task_meta.get(task_id, {})
log_operation_event(
domain="generation_ai_batch",
event_type="CHILD_ENQUEUE_START",
event_status="started",
source="api",
user_id=meta.get("user_id"),
group_id=meta.get("parent_task_id") or task_id,
task_id=task_id,
detail={"generation_index": meta.get("generation_index")},
)
try:
chatapi_create_generation_task.delay(task_id)
await log_task_event(
task_id=task_id,
event_type="CHILD_ENQUEUE_SUCCESS",
to_status="generating",
to_stage="queued",
detail={"task_id": task_id},
)
log_operation_event(
domain="generation_ai_batch",
event_type="CHILD_ENQUEUE_SUCCESS",
event_status="success",
source="api",
user_id=meta.get("user_id"),
group_id=meta.get("parent_task_id") or task_id,
task_id=task_id,
detail={"generation_index": meta.get("generation_index")},
)
except Exception as exc:
failed_ids.append(task_id)
await db.rollback()
failed_task = await mark_chat_generation_task_failed_and_refund_once(
db,
task_id=task_id,
error_message=f"任务队列投递失败: {exc}",
pipeline_stage="failed",
)
await aggregate_parent_for_child(db, failed_task)
await db.commit()
await log_task_event(
task_id=task_id,
event_type="CHILD_ENQUEUE_FAILED",
to_status="failed",
to_stage="failed",
message=str(exc),
detail={"task_id": task_id},
)
log_operation_event(
domain="generation_ai_batch",
event_type="CHILD_ENQUEUE_FAILED",
event_status="failed",
source="api",
user_id=getattr(failed_task, "user_id", None),
group_id=getattr(failed_task, "parent_task_id", None) or task_id,
task_id=task_id,
message=str(exc),
detail={"physical_files_deleted": False},
error=str(exc),
)
return failed_ids
@@ -0,0 +1,441 @@
from __future__ import annotations
from collections import Counter, defaultdict
from datetime import datetime, timezone
from typing import Iterable, Sequence
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.generation_task import (
ChatGenerationPipelineStage,
ChatGenerationTaskStatus,
GenerationMode,
)
from app.models.chat_generation_task import ChatGenerationTask
from app.services.operation_log_service import log_operation_event
from app.services.video_upscale.guard_service import assert_no_recoverable_failed_upscale_tasks
from app.services.resource_accounting_service import (
SOURCE_MODEL_CHAT_TASK,
soft_delete_resources_by_source,
)
ACTIVE_STAGES = {
ChatGenerationPipelineStage.QUEUED.value,
ChatGenerationPipelineStage.PREPARING.value,
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
ChatGenerationPipelineStage.WAITING_REMOTE.value,
ChatGenerationPipelineStage.POLLING.value,
ChatGenerationPipelineStage.RESULT_READY.value,
ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value,
ChatGenerationPipelineStage.DOWNLOADING.value,
ChatGenerationPipelineStage.RETRY_WAITING.value,
ChatGenerationPipelineStage.UPSCALE_QUEUED.value,
ChatGenerationPipelineStage.UPSCALE_PROCESSING.value,
ChatGenerationPipelineStage.UPSCALE_POLLING.value,
ChatGenerationPipelineStage.UPSCALE_DOWNLOADING.value,
ChatGenerationPipelineStage.UPSCALE_FINALIZING.value,
ChatGenerationPipelineStage.UPSCALE_RETRY_WAITING.value,
}
def is_task_active(task: ChatGenerationTask) -> bool:
return task.deleted_at is None and (
task.status == ChatGenerationTaskStatus.GENERATING.value
or (task.pipeline_stage or "") in ACTIVE_STAGES
)
def get_display_status(task: ChatGenerationTask) -> str:
if task.deleted_at is not None:
return "deleted"
if task.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value:
return "download_failed"
if task.pipeline_stage == ChatGenerationPipelineStage.UPSCALE_FAILED.value:
return "failed"
return task.status or ChatGenerationTaskStatus.PENDING.value
async def load_children_map(
db: AsyncSession,
parent_ids: Sequence[str] | Iterable[str],
*,
include_deleted: bool = True,
) -> dict[str, list[ChatGenerationTask]]:
ids = list(dict.fromkeys(str(item) for item in parent_ids if item))
if not ids:
return {}
query = select(ChatGenerationTask).where(
ChatGenerationTask.parent_task_id.in_(ids),
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_CHILD.value,
)
if not include_deleted:
query = query.where(ChatGenerationTask.deleted_at.is_(None))
result = await db.execute(
query.order_by(
ChatGenerationTask.parent_task_id.asc(),
ChatGenerationTask.generation_index.asc(),
ChatGenerationTask.created_at.asc(),
)
)
grouped: dict[str, list[ChatGenerationTask]] = defaultdict(list)
for task in result.scalars().all():
if task.parent_task_id:
grouped[task.parent_task_id].append(task)
return dict(grouped)
async def load_task_and_children(
db: AsyncSession,
*,
task_id: str,
user_id: str | None = None,
include_deleted_children: bool = True,
) -> tuple[ChatGenerationTask | None, list[ChatGenerationTask]]:
query = select(ChatGenerationTask).where(ChatGenerationTask.id == task_id)
if user_id:
query = query.where(ChatGenerationTask.user_id == user_id)
result = await db.execute(query.limit(1))
task = result.scalar_one_or_none()
if not task:
return None, []
if task.generation_mode == GenerationMode.CHATAPI_CHILD.value and task.parent_task_id:
parent_result = await db.execute(
select(ChatGenerationTask).where(ChatGenerationTask.id == task.parent_task_id).limit(1)
)
parent = parent_result.scalar_one_or_none()
return parent or task, [task]
if task.generation_mode != GenerationMode.CHATAPI_MAIN.value:
return task, []
children_map = await load_children_map(
db,
[task.id],
include_deleted=include_deleted_children,
)
return task, children_map.get(task.id, [])
def _generation_result_status(task: ChatGenerationTask) -> str:
"""返回任务真实生成结果,不受资源软删除影响。"""
if task.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value:
return "download_failed"
if task.status == ChatGenerationTaskStatus.FAILED.value or (task.pipeline_stage or "") in {
ChatGenerationPipelineStage.FAILED.value,
ChatGenerationPipelineStage.TIMEOUT.value,
ChatGenerationPipelineStage.UPSCALE_FAILED.value,
}:
return "failed"
if is_task_active(task):
return "generating"
if task.status == ChatGenerationTaskStatus.COMPLETED.value:
return "completed"
return task.status or "pending"
def _build_summary(children: list[ChatGenerationTask]) -> str | None:
if not children:
return None
result_counters: Counter[str] = Counter(_generation_result_status(child) for child in children)
labels = {
"completed": "完成",
"failed": "生成失败",
"download_failed": "下载失败",
"generating": "生成中",
"pending": "待处理",
}
parts = [f"{count}{labels.get(status, status)}" for status, count in result_counters.items() if count]
deleted_count = sum(1 for child in children if child.deleted_at is not None)
if deleted_count:
parts.append(f"{deleted_count}项资源已删除")
return f"{len(children)}项中" + "".join(parts)
async def aggregate_main_task_status(
db: AsyncSession,
*,
parent_task_id: str,
) -> ChatGenerationTask | None:
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id == parent_task_id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_MAIN.value,
)
.with_for_update()
.limit(1)
)
main = result.scalar_one_or_none()
if not main or main.deleted_at is not None:
return main
children_map = await load_children_map(db, [parent_task_id], include_deleted=True)
children = children_map.get(parent_task_id, [])
if not children:
return main
previous_status = main.status
previous_stage = main.pipeline_stage
active_children = [child for child in children if is_task_active(child)]
failed_children = [
child
for child in children
if (
child.status == ChatGenerationTaskStatus.FAILED.value
or (child.pipeline_stage or "") in {
ChatGenerationPipelineStage.FAILED.value,
ChatGenerationPipelineStage.TIMEOUT.value,
ChatGenerationPipelineStage.DOWNLOAD_FAILED.value,
ChatGenerationPipelineStage.UPSCALE_FAILED.value,
}
)
]
completed_children = [
child for child in children if child.status == ChatGenerationTaskStatus.COMPLETED.value
]
if active_children:
main.status = ChatGenerationTaskStatus.GENERATING.value
main.pipeline_stage = active_children[0].pipeline_stage or ChatGenerationPipelineStage.QUEUED.value
main.generated_at = None
main.error_message = _build_summary(children)
elif failed_children:
main.status = ChatGenerationTaskStatus.FAILED.value
if any(child.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value for child in failed_children):
main.pipeline_stage = ChatGenerationPipelineStage.DOWNLOAD_FAILED.value
elif any(child.pipeline_stage == ChatGenerationPipelineStage.UPSCALE_FAILED.value for child in failed_children):
main.pipeline_stage = ChatGenerationPipelineStage.UPSCALE_FAILED.value
else:
main.pipeline_stage = ChatGenerationPipelineStage.FAILED.value
main.generated_at = max(
(child.generated_at for child in completed_children if child.generated_at),
default=datetime.now(timezone.utc),
)
main.error_message = _build_summary(children)
else:
# 所有子任务真实生成结果均成功;资源是否软删除不改变生成历史终态。
main.status = ChatGenerationTaskStatus.COMPLETED.value
main.pipeline_stage = ChatGenerationPipelineStage.DONE.value
main.generated_at = max(
(child.generated_at for child in children if child.generated_at),
default=main.generated_at or datetime.now(timezone.utc),
)
main.error_message = None
if main.gen_type == "video":
main.credits_cost = round(sum(float(child.credits_cost or 0) for child in children), 2)
main.text_credits_cost = round(sum(float(child.text_credits_cost or 0) for child in children), 2)
main.text_tokens_used = sum(int(child.text_tokens_used or 0) for child in children)
main.image_tokens_used = sum(int(child.image_tokens_used or 0) for child in children)
main.video_tokens_used = sum(int(child.video_tokens_used or 0) for child in children)
main.retry_count = sum(int(child.retry_count or 0) for child in children)
main.poll_count = sum(int(child.poll_count or 0) for child in children)
await db.flush()
log_operation_event(
domain="generation_ai_batch",
event_type="MAIN_STATUS_AGGREGATED",
event_status="success",
source="service",
user_id=main.user_id,
group_id=main.id,
task_id=main.id,
detail={
"before_status": previous_status,
"before_stage": previous_stage,
"after_status": main.status,
"after_stage": main.pipeline_stage,
"summary": _build_summary(children),
},
)
return main
async def aggregate_parent_for_child(db: AsyncSession, child: ChatGenerationTask | None) -> ChatGenerationTask | None:
if not child or child.generation_mode != GenerationMode.CHATAPI_CHILD.value or not child.parent_task_id:
return None
# 项目关闭了 autoflush,先显式 flush 子任务的终态,确保聚合查询读取到本事务最新状态。
await db.flush()
return await aggregate_main_task_status(db, parent_task_id=str(child.parent_task_id))
async def soft_delete_child_tasks_batch(
db: AsyncSession,
*,
child_task_ids: Sequence[str] | Iterable[str],
user_id: str,
deleted_at: datetime | None = None,
require_completed: bool = False,
) -> int:
ids = list(dict.fromkeys(str(item) for item in child_task_ids if item))
if not ids:
return 0
deleted_at = deleted_at or datetime.now(timezone.utc)
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id.in_(ids),
ChatGenerationTask.user_id == user_id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_CHILD.value,
)
.with_for_update()
)
children = list(result.scalars().all())
found_ids = {str(child.id) for child in children}
missing_ids = [item for item in ids if item not in found_ids]
if missing_ids:
raise HTTPException(status_code=404, detail=f"子任务不存在: {','.join(missing_ids)}")
active_children = [child for child in children if child.deleted_at is None]
running_ids = [child.id for child in active_children if is_task_active(child)]
if running_ids:
raise HTTPException(status_code=400, detail=f"仍有 {len(running_ids)} 个子任务生成中,暂不能删除")
await assert_no_recoverable_failed_upscale_tasks(db, [str(child.id) for child in active_children])
if require_completed:
invalid_ids = [
child.id for child in active_children
if child.status != ChatGenerationTaskStatus.COMPLETED.value or child.generated_at is None
]
if invalid_ids:
raise HTTPException(status_code=409, detail=f"只有生成完成的资源才能从素材云删除: {','.join(invalid_ids)}")
source_ids = [str(child.id) for child in active_children]
freed_size = await soft_delete_resources_by_source(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=source_ids,
deleted_at=deleted_at,
)
parent_ids = list(dict.fromkeys(str(child.parent_task_id) for child in active_children if child.parent_task_id))
for child in active_children:
child.deleted_at = deleted_at
await db.flush()
for parent_id in parent_ids:
await aggregate_main_task_status(db, parent_task_id=parent_id)
return int(freed_size or 0)
async def soft_delete_child_task(
db: AsyncSession,
*,
child_task_id: str,
user_id: str,
deleted_at: datetime | None = None,
) -> int:
deleted_at = deleted_at or datetime.now(timezone.utc)
detail_result = await db.execute(
select(
ChatGenerationTask.parent_task_id,
ChatGenerationTask.generation_index,
).where(
ChatGenerationTask.id == child_task_id,
ChatGenerationTask.user_id == user_id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_CHILD.value,
).limit(1)
)
detail = detail_result.one_or_none()
if not detail:
raise HTTPException(status_code=404, detail="子任务不存在")
log_operation_event(
domain="generation_ai_batch",
event_type="CHILD_RESOURCE_DELETE_START",
event_status="started",
source="service",
user_id=user_id,
group_id=detail.parent_task_id,
task_id=child_task_id,
detail={"generation_index": detail.generation_index},
)
freed_size = await soft_delete_child_tasks_batch(
db,
child_task_ids=[child_task_id],
user_id=user_id,
deleted_at=deleted_at,
)
log_operation_event(
domain="generation_ai_batch",
event_type="CHILD_RESOURCE_DELETE_SUCCESS",
event_status="success",
source="service",
user_id=user_id,
group_id=detail.parent_task_id,
task_id=child_task_id,
detail={"generation_index": detail.generation_index, "freed_size_bytes": freed_size},
)
return freed_size
async def soft_delete_top_level_task_group(
db: AsyncSession,
*,
task_id: str,
user_id: str,
deleted_at: datetime | None = None,
) -> int:
deleted_at = deleted_at or datetime.now(timezone.utc)
result = await db.execute(
select(ChatGenerationTask)
.where(
ChatGenerationTask.id == task_id,
ChatGenerationTask.user_id == user_id,
ChatGenerationTask.generation_mode.in_(
[GenerationMode.CHATAPI_ASYNC.value, GenerationMode.CHATAPI_MAIN.value]
),
ChatGenerationTask.deleted_at.is_(None),
)
.with_for_update()
.limit(1)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.generation_mode == GenerationMode.CHATAPI_ASYNC.value:
if is_task_active(task):
raise HTTPException(status_code=400, detail="当前任务正在生成中,暂不能删除")
await assert_no_recoverable_failed_upscale_tasks(db, [str(task.id)])
freed_size = await soft_delete_resources_by_source(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=[task.id],
deleted_at=deleted_at,
)
task.deleted_at = deleted_at
await db.flush()
return int(freed_size or 0)
children_map = await load_children_map(db, [task.id], include_deleted=True)
children = children_map.get(task.id, [])
active_ids = [child.id for child in children if is_task_active(child)]
if active_ids:
raise HTTPException(status_code=400, detail=f"任务组仍有 {len(active_ids)} 个子任务生成中,暂不能删除")
active_children = [child for child in children if child.deleted_at is None]
child_ids = [child.id for child in active_children]
await assert_no_recoverable_failed_upscale_tasks(db, [str(child_id) for child_id in child_ids])
freed_size = await soft_delete_resources_by_source(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=child_ids,
deleted_at=deleted_at,
)
for child in active_children:
child.deleted_at = deleted_at
task.deleted_at = deleted_at
await db.flush()
log_operation_event(
domain="generation_ai_batch",
event_type="BATCH_GROUP_DELETE_SUCCESS",
event_status="success",
source="service",
user_id=user_id,
group_id=task.id,
task_id=task.id,
detail={
"child_task_ids": child_ids,
"freed_size_bytes": int(freed_size or 0),
"physical_files_deleted": False,
},
)
return int(freed_size or 0)
@@ -470,6 +470,7 @@ async def charge_generation_media_by_params(
source_step_id: str | None = None, source_step_id: str | None = None,
source_step_code: str | None = None, source_step_code: str | None = None,
billing_scene: str | None = None, billing_scene: str | None = None,
quantity: int = 1,
) -> BillingSummary: ) -> BillingSummary:
"""图片/视频媒体生成扣费。 """图片/视频媒体生成扣费。
@@ -478,6 +479,7 @@ async def charge_generation_media_by_params(
""" """
project_name = project_name or "AI生成任务" project_name = project_name or "AI生成任务"
gen_type = (gen_type or "").lower().strip() gen_type = (gen_type or "").lower().strip()
quantity = max(1, int(quantity or 1))
attempt_no = attempt_no or await get_next_credit_attempt_no( attempt_no = attempt_no or await get_next_credit_attempt_no(
db, db,
owner_type=owner_type, owner_type=owner_type,
@@ -508,13 +510,14 @@ async def charge_generation_media_by_params(
if gen_type == "image": if gen_type == "image":
size = image_size or "2K" size = image_size or "2K"
amount = await calc_image_credits(db, size, engine_id=engine_id) unit_amount = await calc_image_credits(db, size, engine_id=engine_id)
amount = round(unit_amount * quantity, 2)
items.append( items.append(
await deduct_credits_locked_once( await deduct_credits_locked_once(
db, db,
user_id=user_id, user_id=user_id,
amount=amount, amount=amount,
description=f"{description_prefix}图片生成", description=f"{description_prefix}图片生成" + (f"×{quantity}" if quantity > 1 else ""),
related_id=record_id, related_id=record_id,
charge_key=CHARGE_MEDIA, charge_key=CHARGE_MEDIA,
biz_key=biz_key, biz_key=biz_key,
@@ -523,17 +526,18 @@ async def charge_generation_media_by_params(
) )
) )
elif gen_type == "video": elif gen_type == "video":
amount = await calc_video_credits( unit_amount = await calc_video_credits(
db, duration or 5, resolution or "720p", db, duration or 5, resolution or "720p",
engine_id=engine_id, engine_id=engine_id,
input_video_duration=input_video_duration, input_video_duration=input_video_duration,
) )
amount = round(unit_amount * quantity, 2)
items.append( items.append(
await deduct_credits_locked_once( await deduct_credits_locked_once(
db, db,
user_id=user_id, user_id=user_id,
amount=amount, amount=amount,
description=f"{description_prefix}视频生成", description=f"{description_prefix}视频生成" + (f"×{quantity}" if quantity > 1 else ""),
related_id=record_id, related_id=record_id,
charge_key=CHARGE_MEDIA, charge_key=CHARGE_MEDIA,
biz_key=biz_key, biz_key=biz_key,
@@ -12,6 +12,7 @@ from app.services.provider_limit import provider_limit
from app.services.resource_accounting_service import safe_file_size from app.services.resource_accounting_service import safe_file_size
from app.services.video_cover_service import create_video_cover_for_local_video from app.services.video_cover_service import create_video_cover_for_local_video
from app.services.video_gen import download_video from app.services.video_gen import download_video
from app.services.video_upscale.media_service import build_part_mp4_path, probe_video
@dataclass(slots=True) @dataclass(slots=True)
@@ -98,6 +99,45 @@ async def _download_video_atomically(remote_url: str, final_path: str) -> str:
raise raise
async def download_video_upscale_source(record: ChatGenerationTask) -> DownloadedGenerationResult:
"""下载超分源视频。
源视频只供后处理使用不生成封面也不作为用户 GeneratedResource
临时文件保持 .part.mp4 后缀校验成功后原子重命名为 .source.mp4
"""
if not record.remote_result_url:
raise ValueError("缺少远程结果URL")
date_dir = _build_storage_date_dir(record)
dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, "_upscale_source", date_dir)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, f"{record.id}.source.mp4")
if not _is_valid_file(dest):
part_path = build_part_mp4_path(dest)
try:
async with provider_limit("result_download", settings.RESULT_DOWNLOAD_MAX_CONCURRENCY):
await download_video(record.remote_result_url, part_path)
if not _is_valid_file(part_path):
raise RuntimeError("超分源视频下载完成但临时文件为空")
await probe_video(part_path)
os.replace(part_path, dest)
except Exception:
_safe_remove(part_path)
raise
else:
await probe_video(dest)
return DownloadedGenerationResult(
url=f"/generate/videos/_upscale_source/{date_dir}/{record.id}.source.mp4",
storage_path=dest,
file_size_bytes=safe_file_size(dest),
resource_type="video",
)
async def download_generation_result(record: ChatGenerationTask) -> DownloadedGenerationResult: async def download_generation_result(record: ChatGenerationTask) -> DownloadedGenerationResult:
if not record.remote_result_url: if not record.remote_result_url:
raise ValueError("缺少远程结果URL") raise ValueError("缺少远程结果URL")
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import json
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Iterable, Sequence from typing import Iterable, Sequence
@@ -24,12 +23,14 @@ from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.user import User from app.models.user import User
from app.schemas.generation_ai import GenerationAIHistoryBatchDeleteOut from app.schemas.generation_ai import GenerationAIHistoryBatchDeleteOut
from app.services.generation.ai.task_group_service import soft_delete_child_tasks_batch
from app.services.module_generation_flow_base_service import is_active_chat_generation_task from app.services.module_generation_flow_base_service import is_active_chat_generation_task
from app.services.module_generation_log_service import log_module_event_file from app.services.module_generation_log_service import log_module_event_file
from app.services.operation_log_service import log_operation_event
# from app.services.operation_log import log_operation # from app.services.operation_log import log_operation
from app.services.video_upscale.guard_service import assert_no_recoverable_failed_upscale_tasks
from app.services.resource_accounting_service import ( from app.services.resource_accounting_service import (
SOURCE_MODEL_CHAT_TASK, SOURCE_MODEL_CHAT_TASK,
SOURCE_MODEL_GENERATION_RECORD,
SOURCE_MODEL_SHOT_SEGMENT, SOURCE_MODEL_SHOT_SEGMENT,
soft_delete_generation_record_resources, soft_delete_generation_record_resources,
soft_delete_resources_by_source, soft_delete_resources_by_source,
@@ -204,6 +205,10 @@ async def _delete_generation_records(
) )
records = list(result.scalars().all()) records = list(result.scalars().all())
_raise_missing_if_any(ids=ids, found_ids=[record.id for record in records], message="项目生成记录不存在或已删除") _raise_missing_if_any(ids=ids, found_ids=[record.id for record in records], message="项目生成记录不存在或已删除")
await assert_no_recoverable_failed_upscale_tasks(
db,
generation_record_ids=[str(record.id) for record in records],
)
invalid_ids = [ invalid_ids = [
record.id record.id
@@ -238,14 +243,21 @@ async def _delete_chat_tasks(
.where( .where(
ChatGenerationTask.id.in_(ids), ChatGenerationTask.id.in_(ids),
ChatGenerationTask.user_id == current_user.id, ChatGenerationTask.user_id == current_user.id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_ASYNC.value, ChatGenerationTask.generation_mode.in_([
ChatGenerationTask.deleted_at.is_(None), GenerationMode.CHATAPI_ASYNC.value,
GenerationMode.CHATAPI_CHILD.value,
]),
) )
.with_for_update() .with_for_update()
) )
tasks = list(result.scalars().all()) tasks = list(result.scalars().all())
_raise_missing_if_any(ids=ids, found_ids=[task.id for task in tasks], message="AI 创作记录不存在或已删除") _raise_missing_if_any(ids=ids, found_ids=[task.id for task in tasks], message="AI 创作记录不存在或已删除")
already_deleted_ids = [str(task.id) for task in tasks if task.deleted_at is not None]
_raise_invalid_if_any(invalid_ids=already_deleted_ids, message="AI 创作记录不存在或已删除", status_code=404)
await assert_no_recoverable_failed_upscale_tasks(db, [str(task.id) for task in tasks])
invalid_ids = [ invalid_ids = [
task.id task.id
for task in tasks for task in tasks
@@ -253,14 +265,49 @@ async def _delete_chat_tasks(
] ]
_raise_invalid_if_any(invalid_ids=invalid_ids, message="AI 创作记录只有生成完成后才能删除") _raise_invalid_if_any(invalid_ids=invalid_ids, message="AI 创作记录只有生成完成后才能删除")
freed_size = await soft_delete_resources_by_source( async_ids = [str(task.id) for task in tasks if task.generation_mode == GenerationMode.CHATAPI_ASYNC.value]
db, child_ids = [str(task.id) for task in tasks if task.generation_mode == GenerationMode.CHATAPI_CHILD.value]
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=[task.id for task in tasks], freed_size = 0
deleted_at=deleted_at, if async_ids:
freed_size += int(await soft_delete_resources_by_source(
db,
source_model=SOURCE_MODEL_CHAT_TASK,
source_ids=async_ids,
deleted_at=deleted_at,
) or 0)
async_id_set = set(async_ids)
for task in tasks:
if str(task.id) in async_id_set:
task.deleted_at = deleted_at
if child_ids:
freed_size += await soft_delete_child_tasks_batch(
db,
child_task_ids=child_ids,
user_id=current_user.id,
deleted_at=deleted_at,
require_completed=True,
)
parent_task_ids = list(dict.fromkeys(
str(task.parent_task_id) for task in tasks if task.parent_task_id
))
log_operation_event(
domain="generation_ai_batch",
event_type="CHILD_RESOURCE_DELETE_SUCCESS",
event_status="success",
source="service",
user_id=current_user.id,
group_id=parent_task_ids[0] if len(parent_task_ids) == 1 else None,
detail={
"batch": True,
"task_ids": [str(task.id) for task in tasks],
"parent_task_ids": parent_task_ids,
"freed_size_bytes": int(freed_size or 0),
"physical_files_deleted": False,
},
) )
for task in tasks:
task.deleted_at = deleted_at
return _build_out( return _build_out(
source=source, source=source,
@@ -319,6 +366,7 @@ async def _soft_delete_module_projects(
_assert_no_active_chat_tasks(task_map.values()) _assert_no_active_chat_tasks(task_map.values())
chat_task_ids = list(task_map.keys()) chat_task_ids = list(task_map.keys())
await assert_no_recoverable_failed_upscale_tasks(db, chat_task_ids)
freed_size = await soft_delete_resources_by_source( freed_size = await soft_delete_resources_by_source(
db, db,
source_model=SOURCE_MODEL_CHAT_TASK, source_model=SOURCE_MODEL_CHAT_TASK,
@@ -14,7 +14,7 @@ from app.config import settings
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
from app.models.model_config import ModelConfig from app.models.model_config import ModelConfig
from app.models.token_usage import TokenUsage from app.models.token_usage import TokenUsage
from app.services.generation_log_service import log_provider_call from app.services.generation.log_service import log_provider_call
from app.services.provider_limit import provider_limit from app.services.provider_limit import provider_limit
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
@@ -36,7 +36,9 @@ def _load_refs(record: ChatGenerationTask) -> list[dict]:
return [] return []
def _build_user_content(record: ChatGenerationTask) -> list[dict[str, Any]]: async def _build_user_content(record: ChatGenerationTask, db: AsyncSession | None = None) -> list[dict[str, Any]]:
from app.utils.media import media_to_base64
if record.gen_type == "image": if record.gen_type == "image":
params = f"图片参数:分辨率档位={record.image_size or '2K'},比例={record.image_proportion or '1:1'},像素={record.image_px or '2048x2048'}" params = f"图片参数:分辨率档位={record.image_size or '2K'},比例={record.image_proportion or '1:1'},像素={record.image_px or '2048x2048'}"
else: else:
@@ -54,7 +56,15 @@ def _build_user_content(record: ChatGenerationTask) -> list[dict[str, Any]]:
ref_url = ref.get("url") or "" ref_url = ref.get("url") or ""
if not ref_url: if not ref_url:
continue continue
url = _absolute_url(ref_url) if db and await get_llm_media_as_base64(db):
if ref_type == "image":
url = await media_to_base64(ref_url, "image/png")
elif ref_type == "video":
url = await media_to_base64(ref_url, "video/mp4")
else:
continue
else:
url = _absolute_url(ref_url)
if ref_type == "image": if ref_type == "image":
parts.append({"type": "image_url", "image_url": {"url": url}}) parts.append({"type": "image_url", "image_url": {"url": url}})
elif ref_type == "video": elif ref_type == "video":
@@ -94,7 +104,7 @@ async def build_prompt_with_chatapi(db: AsyncSession, record: ChatGenerationTask
"model": config.model_name, "model": config.model_name,
"messages": [ "messages": [
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{"role": "user", "content": _build_user_content(record)}, {"role": "user", "content": await _build_user_content(record, db)},
], ],
"max_tokens": config.max_tokens, "max_tokens": config.max_tokens,
"temperature": config.temperature, "temperature": config.temperature,
@@ -13,10 +13,11 @@ from app.config import settings
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
from app.models.image_engine import ImageEngine from app.models.image_engine import ImageEngine
from app.models.video_engine import VideoEngine from app.models.video_engine import VideoEngine
from app.services.generation_log_service import log_provider_call from app.services.generation.log_service import log_provider_call
from app.services.image_gen import poll_image_task_status, submit_image_task from app.services.image_gen import ImageProviderError, poll_image_task_status, submit_image_task
from app.services.provider_limit import provider_limit from app.services.provider_limit import provider_limit
from app.services.video_gen import poll_task_status, submit_video_task from app.services.video_gen import poll_task_status, submit_video_task
from app.types.generation.provider import ImageProviderBatchResult
def _loads(data: str | None) -> dict: def _loads(data: str | None) -> dict:
@@ -29,8 +30,17 @@ def _loads(data: str | None) -> dict:
return {} return {}
def _try_json(value: Any) -> Any:
if not isinstance(value, str):
return value
try:
return json.loads(value)
except Exception:
return None
async def get_runtime_engine(db: AsyncSession, task: ChatGenerationTask) -> Any: async def get_runtime_engine(db: AsyncSession, task: ChatGenerationTask) -> Any:
"""Use frozen snapshot for historical params, current DB row only for secret api_key.""" """使用任务快照冻结历史参数,只从当前引擎记录读取密钥。"""
snapshot = _loads(task.engine_snapshot_json) snapshot = _loads(task.engine_snapshot_json)
if not task.engine_id: if not task.engine_id:
raise ValueError("缺少 engine_id") raise ValueError("缺少 engine_id")
@@ -51,6 +61,31 @@ async def get_runtime_engine(db: AsyncSession, task: ChatGenerationTask) -> Any:
generate_url=snapshot.get("generate_url") or getattr(engine, "generate_url", ""), generate_url=snapshot.get("generate_url") or getattr(engine, "generate_url", ""),
query_url=snapshot.get("query_url") or getattr(engine, "query_url", ""), query_url=snapshot.get("query_url") or getattr(engine, "query_url", ""),
default_size=snapshot.get("default_size") or getattr(engine, "default_size", "2K"), default_size=snapshot.get("default_size") or getattr(engine, "default_size", "2K"),
multi_generation_enabled=bool(
snapshot.get("multi_generation_enabled")
if snapshot.get("multi_generation_enabled") is not None
else getattr(engine, "multi_generation_enabled", False)
),
max_generation_count=int(
snapshot.get("max_generation_count")
or getattr(engine, "max_generation_count", 1)
or 1
),
multi_image_max_images=int(
snapshot.get("multi_image_max_images")
or getattr(engine, "multi_image_max_images", 15)
or 15
),
max_reference_image_count=int(
snapshot.get("max_reference_image_count")
if snapshot.get("max_reference_image_count") is not None
else getattr(engine, "max_reference_image_count", 14)
),
output_format=(
snapshot.get("output_format")
if snapshot.get("output_format") is not None
else getattr(engine, "output_format", "")
) or "",
) )
@@ -58,22 +93,16 @@ async def create_provider_task(db: AsyncSession, task: ChatGenerationTask) -> di
if task.gen_type == "video": if task.gen_type == "video":
return await _create_video_task(db, task) return await _create_video_task(db, task)
if task.gen_type == "image": if task.gen_type == "image":
return await _create_image_sync_task(db, task) return await create_image_sync_result(db, task)
raise ValueError(f"不支持的生成类型: {task.gen_type}") raise ValueError(f"不支持的生成类型: {task.gen_type}")
async def _create_video_task(db: AsyncSession, task: ChatGenerationTask) -> dict: async def _create_video_task(db: AsyncSession, task: ChatGenerationTask) -> dict:
"""Create video provider task through the original Ark SDK async task API."""
engine = await get_runtime_engine(db, task) engine = await get_runtime_engine(db, task)
started = time.perf_counter() started = time.perf_counter()
async with provider_limit("ark_video_create", settings.ARK_VIDEO_CREATE_MAX_CONCURRENCY): async with provider_limit("ark_video_create", settings.ARK_VIDEO_CREATE_MAX_CONCURRENCY):
try: try:
provider_task_id = await submit_video_task( provider_task_id = await submit_video_task(None, engine, task, include_media_references=True)
db,
engine,
task,
include_media_references=True,
)
response = {"task_id": provider_task_id} response = {"task_id": provider_task_id}
await log_provider_call( await log_provider_call(
task, task,
@@ -101,65 +130,90 @@ async def _create_video_task(db: AsyncSession, task: ChatGenerationTask) -> dict
raise raise
async def _create_image_sync_task(db: AsyncSession, task: ChatGenerationTask) -> dict: async def create_image_sync_batch_result(
"""Run the original synchronous image generation SDK under Celery control. db: AsyncSession,
task: ChatGenerationTask,
The legacy image SDK returns a final remote image URL immediately. We do *,
NOT use image_generation.tasks.create here, so image generation stays aligned generation_count: int,
with the old working flow while no longer blocking the FastAPI request. ) -> ImageProviderBatchResult:
"""
engine = await get_runtime_engine(db, task) engine = await get_runtime_engine(db, task)
return await create_image_sync_batch_result_with_engine(
task,
engine,
generation_count=generation_count,
)
async def create_image_sync_batch_result_with_engine(
task: ChatGenerationTask,
engine: Any,
*,
generation_count: int,
) -> ImageProviderBatchResult:
"""执行一次同步图片请求。
generation_count > 1 时是一次组图 API 调用失败后绝不退化为多次单图调用
"""
count = max(1, int(generation_count or 1))
started = time.perf_counter() started = time.perf_counter()
api_type = "image_sync_batch_create" if count > 1 else "image_sync_create"
async with provider_limit("ark_image_sync_create", settings.ARK_IMAGE_CREATE_MAX_CONCURRENCY): async with provider_limit("ark_image_sync_create", settings.ARK_IMAGE_CREATE_MAX_CONCURRENCY):
try: try:
result = await asyncio.to_thread( result = await asyncio.to_thread(
submit_image_task, submit_image_task,
db, None,
engine, engine,
task, task,
include_media_references=True, include_media_references=True,
generation_count=count,
) )
if result.get("error"): response_data = result.get("response_data") or result
raise RuntimeError(result.get("error"))
response_data = _try_json(result.get("response_data")) or result
await log_provider_call( await log_provider_call(
task, task,
provider=engine.provider, provider=engine.provider,
api_type="image_sync_create", api_type=api_type,
model=engine.model_name, model=engine.model_name,
engine_id=task.engine_id, engine_id=task.engine_id,
status="success", status="success",
latency_ms=int((time.perf_counter() - started) * 1000), latency_ms=int((time.perf_counter() - started) * 1000),
provider_task_id=None, provider_task_id=None,
response_data=response_data, response_data=response_data,
total_tokens=int(result.get("image_tokens", 0) or 0),
) )
return { return result
"task_id": None,
"remote_result_url": result.get("image_url"),
"image_tokens": result.get("image_tokens", 0) or 0,
"response_data": response_data,
}
except Exception as exc: except Exception as exc:
error_message = exc.safe_message if isinstance(exc, ImageProviderError) else str(exc)
await log_provider_call( await log_provider_call(
task, task,
provider=engine.provider, provider=engine.provider,
api_type="image_sync_create", api_type=api_type,
model=engine.model_name, model=engine.model_name,
engine_id=task.engine_id, engine_id=task.engine_id,
status="failed", status="failed",
latency_ms=int((time.perf_counter() - started) * 1000), latency_ms=int((time.perf_counter() - started) * 1000),
error_message=str(exc), error_message=error_message,
response_data=exc.as_dict() if isinstance(exc, ImageProviderError) else None,
) )
raise raise
def _try_json(text: Any) -> Any: async def create_image_sync_result(db: AsyncSession, task: ChatGenerationTask) -> dict:
if not isinstance(text, str): result = await create_image_sync_batch_result(db, task, generation_count=1)
return text items = result.get("items") or []
try: if len(items) != 1:
return json.loads(text) raise RuntimeError(f"图片供应商单图返回数量异常,期望 1,实际 {len(items)}")
except Exception: item = items[0]
return None if item.get("error_message"):
raise RuntimeError(item.get("error_message") or "图片生成失败")
image_url = item.get("remote_result_url")
if not image_url:
raise RuntimeError("图片供应商未返回有效图片地址")
return {
"task_id": None,
"remote_result_url": image_url,
"image_tokens": int(result.get("image_tokens", 0) or 0),
"response_data": result.get("response_data") or {},
}
async def poll_provider_task(db: AsyncSession, task: ChatGenerationTask) -> dict: async def poll_provider_task(db: AsyncSession, task: ChatGenerationTask) -> dict:
@@ -15,6 +15,7 @@ from app.enums.generation_task import (
ChatGenerationPipelineStage, ChatGenerationPipelineStage,
ChatGenerationTaskEventType, ChatGenerationTaskEventType,
ChatGenerationTaskStatus, ChatGenerationTaskStatus,
GenerationMode,
GenerationType, GenerationType,
) )
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
@@ -25,10 +26,10 @@ from app.services.celery_download_recovery_service import (
postpone_download_active_check, postpone_download_active_check,
remove_download_active, remove_download_active,
) )
from app.services.generation_log_service import log_task_event from app.services.generation.log_service import log_task_event
from app.services.generation_module_hook_service import notify_chat_generation_task_finished from app.services.generation.module_hook_service import notify_chat_generation_task_finished
from app.services.generation_poll_schedule_service import ensure_video_poll_fields, is_poll_not_due, is_video_generation_task from app.services.generation.poll_schedule_service import ensure_video_poll_fields, is_poll_not_due, is_video_generation_task
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation.refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.redis_registry_service import ( from app.services.redis_registry_service import (
redis_get_due_registry_ids, redis_get_due_registry_ids,
redis_get_registry_payloads, redis_get_registry_payloads,
@@ -341,6 +342,8 @@ async def _mark_timeout(
pipeline_stage=ChatGenerationPipelineStage.TIMEOUT.value, pipeline_stage=ChatGenerationPipelineStage.TIMEOUT.value,
) )
await notify_chat_generation_task_finished(db, task) await notify_chat_generation_task_finished(db, task)
from app.services.generation.ai.task_group_service import aggregate_parent_for_child
await aggregate_parent_for_child(db, task)
await db.commit() await db.commit()
await _remove_poll_active(task.id) await _remove_poll_active(task.id)
await log_task_event( await log_task_event(
@@ -367,6 +370,8 @@ async def _mark_failed(
pipeline_stage=ChatGenerationPipelineStage.FAILED.value, pipeline_stage=ChatGenerationPipelineStage.FAILED.value,
) )
await notify_chat_generation_task_finished(db, task) await notify_chat_generation_task_finished(db, task)
from app.services.generation.ai.task_group_service import aggregate_parent_for_child
await aggregate_parent_for_child(db, task)
await db.commit() await db.commit()
await _remove_poll_active(task.id) await _remove_poll_active(task.id)
await log_task_event(task, event_type=event_type, message=task.error_message, detail=detail) await log_task_event(task, event_type=event_type, message=task.error_message, detail=detail)
@@ -407,6 +412,12 @@ async def recover_one_generation_task(
await _remove_poll_active(task.id) await _remove_poll_active(task.id)
return "clean_not_generating" return "clean_not_generating"
if bool(getattr(task, "video_upscale_enabled_snapshot", False)) and str(task.pipeline_stage or "").startswith("upscale_"):
# 原视频已经进入超分流水线,后续由 video_upscale 恢复扫描处理。
# 这里禁止再次投递原结果下载,避免覆盖保留的 source.mp4 或提前生成用户资源。
await _remove_poll_active(task.id)
return "delegate_video_upscale_recovery"
has_remote_result = bool(str(task.remote_result_url or "").strip()) has_remote_result = bool(str(task.remote_result_url or "").strip())
has_provider_task_id = bool(str(task.provider_task_id or "").strip() or str(task.seedance_task_id or "").strip()) has_provider_task_id = bool(str(task.provider_task_id or "").strip() or str(task.seedance_task_id or "").strip())
is_deadline_expired = bool(task.deadline_at and _is_expired(task.deadline_at, current_time)) is_deadline_expired = bool(task.deadline_at and _is_expired(task.deadline_at, current_time))
@@ -590,6 +601,99 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
checked_ids: set[str] = set() checked_ids: set[str] = set()
results: dict[str, int] = {} results: dict[str, int] = {}
# 图片多份主任务只补投递,不在恢复服务内直接调用供应商。
# 有效 claim 未过期时必须跳过,防止与正在运行的 Worker 重复调用组图 API。
from app.tasks.generation_create_tasks import chatapi_create_generation_task
image_main_cursor: str | None = None
image_main_batch_size = max(1, int(settings.GENERATION_RECOVERY_BATCH_SIZE or 100))
while True:
image_main_query = select(ChatGenerationTask).where(
ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_MAIN.value,
ChatGenerationTask.gen_type == GenerationType.IMAGE.value,
ChatGenerationTask.status == ChatGenerationTaskStatus.GENERATING.value,
ChatGenerationTask.pipeline_stage.in_([
ChatGenerationPipelineStage.QUEUED.value,
ChatGenerationPipelineStage.PREPARING.value,
ChatGenerationPipelineStage.CREATING_PROVIDER_TASK.value,
]),
)
if image_main_cursor:
image_main_query = image_main_query.where(ChatGenerationTask.id > image_main_cursor)
image_main_result = await db.execute(
image_main_query.order_by(ChatGenerationTask.id.asc())
.limit(image_main_batch_size)
.with_for_update()
)
image_mains = list(image_main_result.scalars().all())
if not image_mains:
break
for main in image_mains:
main_id = str(main.id)
image_main_cursor = main_id
checked_ids.add(main_id)
child_result = await db.execute(
select(ChatGenerationTask.id)
.where(
ChatGenerationTask.parent_task_id == main_id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_CHILD.value,
)
.limit(1)
)
if child_result.scalar_one_or_none() is not None:
main.provider_create_claim_token = None
main.provider_create_lease_until = None
await db.commit()
results["image_main_already_split"] = results.get("image_main_already_split", 0) + 1
continue
now = _now()
lease_until = ensure_aware_utc(main.provider_create_lease_until)
lease_alive = bool(main.provider_create_claim_token and lease_until and lease_until > now)
if lease_alive:
await db.commit()
results["image_main_claim_alive"] = results.get("image_main_claim_alive", 0) + 1
continue
if _is_expired(main.deadline_at, now):
main.provider_create_claim_token = None
main.provider_create_lease_until = None
await mark_chat_generation_task_failed_and_refund_once(
db,
task=main,
error_message="图片批量生成任务超时",
pipeline_stage=ChatGenerationPipelineStage.TIMEOUT.value,
)
await db.commit()
results["image_main_timeout"] = results.get("image_main_timeout", 0) + 1
continue
if main.provider_create_claim_token or main.provider_create_lease_until:
main.provider_create_claim_token = None
main.provider_create_lease_until = None
main.pipeline_stage = ChatGenerationPipelineStage.QUEUED.value
await log_task_event(
main,
event_type=ChatGenerationTaskEventType.IMAGE_MAIN_CLAIM_EXPIRED.value,
message="图片主任务供应商执行租约已过期,恢复重新投递",
)
await db.commit()
try:
chatapi_create_generation_task.apply_async(
args=[main_id],
queue=CeleryQueue.GEN_CHATAPI_CREATE.value,
countdown=0,
)
results["recover_image_main_create"] = results.get("recover_image_main_create", 0) + 1
except Exception as exc:
logger.exception("恢复投递图片主任务失败 task_id=%s: %s", main_id, exc)
results["recover_image_main_enqueue_failed"] = results.get("recover_image_main_enqueue_failed", 0) + 1
if len(image_mains) < image_main_batch_size:
break
due_poll_ids = await redis_get_due_registry_ids( due_poll_ids = await redis_get_due_registry_ids(
zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY, zset_key=settings.POLL_ACTIVE_REDIS_ZSET_KEY,
limit=int(settings.POLL_RECOVERY_BATCH_SIZE or settings.GENERATION_RECOVERY_BATCH_SIZE or 100), limit=int(settings.POLL_RECOVERY_BATCH_SIZE or settings.GENERATION_RECOVERY_BATCH_SIZE or 100),
@@ -673,6 +777,31 @@ async def recover_generation_tasks_once(db: AsyncSession) -> dict[str, Any]:
if len(tasks) < batch_size or progressed_this_round <= 0: if len(tasks) < batch_size or progressed_this_round <= 0:
break break
# 子任务可能在 worker 中断前已进入终态但主任务尚未汇总,按稳定游标完整重算全部主任务。
from app.services.generation.ai.task_group_service import aggregate_main_task_status
reconciled = 0
main_cursor: str | None = None
while True:
main_query = select(ChatGenerationTask.id).where(
ChatGenerationTask.deleted_at.is_(None),
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_MAIN.value,
)
if main_cursor:
main_query = main_query.where(ChatGenerationTask.id > main_cursor)
main_result = await db.execute(main_query.order_by(ChatGenerationTask.id.asc()).limit(batch_size))
parent_ids = list(main_result.scalars().all())
if not parent_ids:
break
for parent_task_id in parent_ids:
main_cursor = str(parent_task_id)
await aggregate_main_task_status(db, parent_task_id=str(parent_task_id))
await db.commit()
reconciled += 1
if len(parent_ids) < batch_size:
break
if reconciled:
results["reconcile_main"] = reconciled
return { return {
"checked": len(checked_ids), "checked": len(checked_ids),
"db_checked": total_db_checked, "db_checked": total_db_checked,
@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
from typing import Iterable
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,7 +9,7 @@ from app.models.credit_record import CreditRecord
from app.models.generation_record import GenerationRecord from app.models.generation_record import GenerationRecord
from app.services.credits import refund_credits from app.services.credits import refund_credits
from app.services.credit_record_meta_service import build_refund_meta_from_charge from app.services.credit_record_meta_service import build_refund_meta_from_charge
from app.services.generation_billing_service import ( from app.services.generation.billing_service import (
CHARGE_MEDIA, CHARGE_MEDIA,
OWNER_CHAT_GENERATION_TASK, OWNER_CHAT_GENERATION_TASK,
OWNER_GENERATION_RECORD, OWNER_GENERATION_RECORD,
@@ -182,7 +180,7 @@ async def mark_chat_generation_task_failed_and_refund_once(
select(ChatGenerationTask) select(ChatGenerationTask)
.where( .where(
ChatGenerationTask.id == task_id, ChatGenerationTask.id == task_id,
ChatGenerationTask.generation_mode.in_(["chatapi_async", "hot_opening_replicate", "shot_replicate"]), ChatGenerationTask.generation_mode.in_(["chatapi_async", "chatapi_main", "chatapi_child", "hot_opening_replicate", "shot_replicate"]),
ChatGenerationTask.deleted_at.is_(None), ChatGenerationTask.deleted_at.is_(None),
) )
.with_for_update() .with_for_update()
@@ -11,22 +11,23 @@ from app.config import settings
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
from app.models.user import User from app.models.user import User
from app.schemas.generation_ai import GenerationAIReference, GenerationAITaskCreate from app.schemas.generation_ai import GenerationAIReference, GenerationAITaskCreate
from app.services.generation_ai_service import ( from app.services.generation.ai.engine_service import (
IMAGE_DEFAULT_PROPORTION, IMAGE_DEFAULT_PROPORTION,
IMAGE_DEFAULT_PX, IMAGE_DEFAULT_PX,
IMAGE_DEFAULT_SIZE, IMAGE_DEFAULT_SIZE,
VIDEO_DEFAULT_RATIO, VIDEO_DEFAULT_RATIO,
VIDEO_DEFAULT_RESOLUTION, VIDEO_DEFAULT_RESOLUTION,
_build_image_snapshot, build_image_snapshot as _build_image_snapshot,
_build_video_snapshot, build_video_snapshot as _build_video_snapshot,
_get_image_engine, get_image_engine as _get_image_engine,
_get_video_engine, get_video_engine as _get_video_engine,
_image_supported_sizes, image_supported_sizes as _image_supported_sizes,
_parse_list,
normalize_px, normalize_px,
parse_json_list as _parse_list,
) )
from app.services.generation_billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params from app.services.generation.billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params
from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.resource_capacity_service import assert_user_resource_capacity_available
from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot
from app.services.private_portrait.reference_resolver import resolve_private_portrait_references from app.services.private_portrait.reference_resolver import resolve_private_portrait_references
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
@@ -128,6 +129,7 @@ async def create_chat_generation_task_for_module(
billing_scene=billing_scene, billing_scene=billing_scene,
) )
snapshot = _build_image_snapshot(engine, size, proportion, px) snapshot = _build_image_snapshot(engine, size, proportion, px)
snapshot["generation_count"] = 1
task = ChatGenerationTask( task = ChatGenerationTask(
id=task_id, id=task_id,
user_id=current_user.id, user_id=current_user.id,
@@ -163,6 +165,12 @@ async def create_chat_generation_task_for_module(
raise HTTPException(status_code=400, detail=f"视频时长不支持: {selected_duration}") raise HTTPException(status_code=400, detail=f"视频时长不支持: {selected_duration}")
if engine.max_duration and selected_duration > engine.max_duration: if engine.max_duration and selected_duration > engine.max_duration:
raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration}") raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration}")
provider_generation_resolution, upscale_enabled_snapshot, upscale_snapshot_json = await build_video_upscale_snapshot(
db,
target_resolution=selected_resolution,
aspect_ratio=ratio,
supported_provider_resolutions=resolutions,
)
media_billing = await charge_generation_media_by_params( media_billing = await charge_generation_media_by_params(
db, db,
user_id=current_user.id, user_id=current_user.id,
@@ -182,6 +190,9 @@ async def create_chat_generation_task_for_module(
billing_scene=billing_scene, billing_scene=billing_scene,
) )
snapshot = _build_video_snapshot(engine, ratio, selected_resolution, selected_duration) snapshot = _build_video_snapshot(engine, ratio, selected_resolution, selected_duration)
snapshot["generation_count"] = 1
snapshot["provider_generation_resolution"] = provider_generation_resolution
snapshot["video_upscale_enabled_snapshot"] = upscale_enabled_snapshot
task = ChatGenerationTask( task = ChatGenerationTask(
id=task_id, id=task_id,
user_id=current_user.id, user_id=current_user.id,
@@ -191,6 +202,9 @@ async def create_chat_generation_task_for_module(
duration=selected_duration, duration=selected_duration,
aspect_ratio=ratio, aspect_ratio=ratio,
resolution=selected_resolution, resolution=selected_resolution,
provider_generation_resolution=provider_generation_resolution,
video_upscale_enabled_snapshot=upscale_enabled_snapshot,
video_upscale_snapshot_json=upscale_snapshot_json,
image_size=image_size or IMAGE_DEFAULT_SIZE, image_size=image_size or IMAGE_DEFAULT_SIZE,
image_proportion=image_proportion or IMAGE_DEFAULT_PROPORTION, image_proportion=image_proportion or IMAGE_DEFAULT_PROPORTION,
image_px=normalize_px(image_px) or IMAGE_DEFAULT_PX, image_px=normalize_px(image_px) or IMAGE_DEFAULT_PX,
@@ -1,14 +1,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
from copy import deepcopy from datetime import datetime
from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import String, cast, func, or_, select from sqlalchemy import String, cast, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from app.config import settings from app.config import settings
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
@@ -35,20 +33,19 @@ from app.schemas.hot_opening_replicate import (
HotOpeningVideoGenerationOut, HotOpeningVideoGenerationOut,
HotOpeningVideoPromptSchemaUpdateRequest, HotOpeningVideoPromptSchemaUpdateRequest,
) )
from app.services.generation_ai_service import ( from app.services.generation.ai.engine_service import (
VIDEO_DEFAULT_DURATION, VIDEO_DEFAULT_DURATION,
VIDEO_DEFAULT_RATIO, VIDEO_DEFAULT_RATIO,
VIDEO_DEFAULT_RESOLUTION, VIDEO_DEFAULT_RESOLUTION,
_get_video_engine, get_video_engine,
_parse_list, parse_json_list,
) )
from app.services.generation_billing_service import charge_module_prompt_usage from app.services.generation.billing_service import charge_module_prompt_usage
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation.refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.generation_task_factory_service import create_chat_generation_task_for_module from app.services.generation.task_factory_service import create_chat_generation_task_for_module
from app.services.hot_opening_video_prompt_service import build_final_video_prompt, optimize_hot_opening_video_prompt, patch_video_prompt_schema_from_client from app.services.hot_opening_video_prompt_service import build_final_video_prompt, optimize_hot_opening_video_prompt, patch_video_prompt_schema_from_client
from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event
from app.services.llm import optimize_prompt from app.services.llm import optimize_prompt
from app.services.resource_accounting_service import soft_delete_chat_task_resources
from app.services.module_generation_flow_base_service import ( from app.services.module_generation_flow_base_service import (
chat_tasks_by_id as _base_chat_tasks_by_id, chat_tasks_by_id as _base_chat_tasks_by_id,
create_module_step as _base_create_step, create_module_step as _base_create_step,
@@ -1021,10 +1018,10 @@ async def generate_image_from_prompt(
async def _resolve_video_prompt_config(db: AsyncSession, req: HotOpeningGenerateVideoPromptRequest) -> dict[str, Any]: async def _resolve_video_prompt_config(db: AsyncSession, req: HotOpeningGenerateVideoPromptRequest) -> dict[str, Any]:
engine = await _get_video_engine(db, req.engine_id) engine = await get_video_engine(db, req.engine_id)
supported_ratios = _parse_list(engine.supported_ratios, []) supported_ratios = parse_json_list(engine.supported_ratios, [])
supported_resolutions = _parse_list(engine.supported_resolutions, []) supported_resolutions = parse_json_list(engine.supported_resolutions, [])
supported_durations = _parse_list(engine.supported_durations, []) supported_durations = parse_json_list(engine.supported_durations, [])
default_ratio = getattr(settings, "HOT_OPENING_DEFAULT_VIDEO_RATIO", None) or VIDEO_DEFAULT_RATIO default_ratio = getattr(settings, "HOT_OPENING_DEFAULT_VIDEO_RATIO", None) or VIDEO_DEFAULT_RATIO
default_resolution = getattr(settings, "HOT_OPENING_DEFAULT_VIDEO_RESOLUTION", None) or VIDEO_DEFAULT_RESOLUTION default_resolution = getattr(settings, "HOT_OPENING_DEFAULT_VIDEO_RESOLUTION", None) or VIDEO_DEFAULT_RESOLUTION
@@ -709,7 +709,7 @@ def build_user_text(
"支持分辨率": _safe_list(video_config.get("supported_resolutions")), "支持分辨率": _safe_list(video_config.get("supported_resolutions")),
"推荐分辨率": get_recommended_resolution(video_ratio, resolution), "推荐分辨率": get_recommended_resolution(video_ratio, resolution),
}, },
"参考素材": references, # "参考素材": references,
"输出要求": { "输出要求": {
"生成类型": infer_generation_type(references), "生成类型": infer_generation_type(references),
"动态时间规划时间段必须严格等于": _time_plan_time_ranges_for_ai(build_time_plan(duration, schema_config_snapshot)), "动态时间规划时间段必须严格等于": _time_plan_time_ranges_for_ai(build_time_plan(duration, schema_config_snapshot)),
@@ -722,6 +722,7 @@ def build_user_text(
"字段启用规则": "只输出 schema 中存在的启用字段;不要输出已禁用字段;不要新增 schema 外字段。", "字段启用规则": "只输出 schema 中存在的启用字段;不要输出已禁用字段;不要新增 schema 外字段。",
"最终提示词限制": "最终提示词下所有字段都不能写入视频时长、秒数、视频比例、清晰度、分辨率、帧率、推荐像素、竖屏、横屏等视频规格参数,这些规格只能写在画面属性/动态时间规划/输出规格限制。", "最终提示词限制": "最终提示词下所有字段都不能写入视频时长、秒数、视频比例、清晰度、分辨率、帧率、推荐像素、竖屏、横屏等视频规格参数,这些规格只能写在画面属性/动态时间规划/输出规格限制。",
"禁止": ["输出 Markdown", "输出 schema 之外的解释文字", "照抄参考素材品牌水印", "生成违法违规内容", "在最终提示词中写入秒数/比例/分辨率/帧率"], "禁止": ["输出 Markdown", "输出 schema 之外的解释文字", "照抄参考素材品牌水印", "生成违法违规内容", "在最终提示词中写入秒数/比例/分辨率/帧率"],
"语言要求": "所有输出内容必须使用简体中文,不得使用英文或其他语言。"
}, },
"必须按此schema输出": client_schema, "必须按此schema输出": client_schema,
}, },
@@ -1536,9 +1537,16 @@ async def optimize_hot_opening_video_prompt(
trace_id: str | None = None, trace_id: str | None = None,
) -> tuple[dict[str, Any], str, dict[str, Any]]: ) -> tuple[dict[str, Any], str, dict[str, Any]]:
duration = int(video_config["duration"]) duration = int(video_config["duration"])
from app.utils.media import media_to_base64, get_llm_media_as_base64
if await get_llm_media_as_base64(db):
video_url_final = await media_to_base64(material_video_url, "video/mp4")
image_url_final = await media_to_base64(generated_image_url, "image/png")
else:
video_url_final = _build_file_url_or_data_uri(material_video_url)
image_url_final = _build_file_url_or_data_uri(generated_image_url)
references = [ references = [
{"type": "video", "url": _build_file_url_or_data_uri(material_video_url)}, {"type": "video", "url": video_url_final},
{"type": "image", "url": _build_file_url_or_data_uri(generated_image_url)}, {"type": "image", "url": image_url_final},
] ]
client_schema = build_dynamic_schema(video_config, schema_config_snapshot) 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) reference_video_fps = int(video_config.get("reference_video_fps") or DEFAULT_REFERENCE_VIDEO_FPS)
+305 -90
View File
@@ -1,9 +1,8 @@
import base64
import json import json
import logging import logging
import mimetypes
import os import os
from datetime import datetime from datetime import datetime
from typing import Any
import httpx import httpx
from sqlalchemy import select from sqlalchemy import select
@@ -11,10 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
from volcenginesdkarkruntime import AsyncArk from volcenginesdkarkruntime import AsyncArk
from app.config import settings from app.config import settings
from app.enums.generation_provider import (
MULTI_IMAGE_PROMPT_TEMPLATE,
ImageProviderErrorType,
)
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX
from app.models.image_engine import ImageEngine from app.models.image_engine import ImageEngine
from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data from app.services.log_config import LOG_DATE_FORMAT, LOG_DIR, encrypt_data, is_enabled
from app.services.generation_provider_types import ( from app.types.generation.provider import (
ImageProviderBatchResult,
ImageProviderItem,
ProviderGenerationRecordLike, ProviderGenerationRecordLike,
ProviderImageEngineLike, ProviderImageEngineLike,
) )
@@ -22,8 +27,39 @@ from app.services.generation_provider_types import (
logger = logging.getLogger("videogen") logger = logging.getLogger("videogen")
class ImageProviderError(RuntimeError):
"""可被生成任务状态机安全收敛的图片供应商异常。"""
def __init__(
self,
message: str,
*,
error_type: ImageProviderErrorType = ImageProviderErrorType.UNKNOWN,
error_code: str | None = None,
retryable: bool = False,
http_status: int | None = None,
provider_request_id: str | None = None,
):
super().__init__(message)
self.safe_message = message
self.error_type = error_type
self.error_code = error_code
self.retryable = retryable
self.http_status = http_status
self.provider_request_id = provider_request_id
def as_dict(self) -> dict[str, Any]:
return {
"error_type": self.error_type.value,
"error_code": self.error_code,
"message": self.safe_message,
"retryable": self.retryable,
"http_status": self.http_status,
"provider_request_id": self.provider_request_id,
}
def _log_image_request(engine: ProviderImageEngineLike, record_id: str, request_data: dict): def _log_image_request(engine: ProviderImageEngineLike, record_id: str, request_data: dict):
"""Log image generation request to log/AiModel/YYYY-MM-DD.log"""
if not is_enabled(): if not is_enabled():
return return
try: try:
@@ -41,14 +77,13 @@ def _log_image_request(engine: ProviderImageEngineLike, record_id: str, request_
"request": request_encrypted, "request": request_encrypted,
"request_length": len(request_str), "request_length": len(request_str),
} }
with open(log_file, "a", encoding="utf-8") as f: with open(log_file, "a", encoding="utf-8") as file:
f.write(json.dumps(entry, ensure_ascii=False) + "\n") file.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception: except Exception:
pass pass
def _log_image_response(record_id: str, response_data: dict, error: str | None = None): def _log_image_response(record_id: str, response_data: dict, error: str | None = None):
"""Log image generation response to log/AiModel/YYYY-MM-DD.log"""
if not is_enabled(): if not is_enabled():
return return
try: try:
@@ -63,17 +98,13 @@ def _log_image_response(record_id: str, response_data: dict, error: str | None =
"response": response_encrypted, "response": response_encrypted,
"error": error, "error": error,
} }
with open(log_file, "a", encoding="utf-8") as f: with open(log_file, "a", encoding="utf-8") as file:
f.write(json.dumps(entry, ensure_ascii=False) + "\n") file.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception: except Exception:
pass pass
async def get_active_image_engine(db: AsyncSession) -> ImageEngine: async def get_active_image_engine(db: AsyncSession) -> ImageEngine:
"""Get the active image engine with highest priority."""
result = await db.execute( result = await db.execute(
select(ImageEngine) select(ImageEngine)
.where(ImageEngine.is_active == True) .where(ImageEngine.is_active == True)
@@ -103,105 +134,291 @@ def _resolve_url(url: str) -> str:
return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}" return f"{settings.BASE_URL.rstrip('/')}/{url.lstrip('/')}"
def _value(obj: Any, name: str, default: Any = None) -> Any:
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(name, default)
return getattr(obj, name, default)
def _jsonable(value: Any) -> Any:
if value is None or isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, dict):
return {str(key): _jsonable(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_jsonable(item) for item in value]
if hasattr(value, "model_dump"):
try:
return _jsonable(value.model_dump())
except Exception:
pass
if hasattr(value, "to_dict"):
try:
return _jsonable(value.to_dict())
except Exception:
pass
result: dict[str, Any] = {}
for key in ("url", "b64_json", "size", "output_format", "error", "code", "message"):
item = getattr(value, key, None)
if item is not None:
result[key] = _jsonable(item)
return result or str(value)
def _safe_text(value: Any, *, limit: int = 1000) -> str:
text = str(value or "").strip()
return text[:limit]
def _classify_provider_exception(exc: Exception) -> ImageProviderError:
if isinstance(exc, ImageProviderError):
return exc
if isinstance(exc, (httpx.TimeoutException, TimeoutError)):
return ImageProviderError(
"图片生成请求超时,请稍后重试",
error_type=ImageProviderErrorType.TIMEOUT,
retryable=True,
)
status_code = getattr(exc, "status_code", None)
request_id = getattr(exc, "request_id", None) or getattr(exc, "x_request_id", None)
code = getattr(exc, "code", None)
raw_message = _safe_text(getattr(exc, "message", None) or exc)
lowered = raw_message.lower()
if status_code == 429 or "rate limit" in lowered or "限流" in raw_message:
error_type = ImageProviderErrorType.RATE_LIMIT
retryable = True
message = "图片生成请求过于频繁,请稍后重试"
elif status_code in {401, 403} or "api key" in lowered or "unauthorized" in lowered:
error_type = ImageProviderErrorType.AUTH
retryable = False
message = "图片引擎鉴权失败,请联系管理员检查配置"
elif status_code and int(status_code) >= 500:
error_type = ImageProviderErrorType.PROVIDER_INTERNAL
retryable = True
message = "图片供应商服务异常,请稍后重试"
elif "sequential_image_generation" in lowered or "not support" in lowered or "unsupported" in lowered:
error_type = ImageProviderErrorType.CAPABILITY_MISMATCH
retryable = False
message = "图片引擎组图能力配置与供应商实际能力不匹配,请联系管理员"
elif "content" in lowered and ("risk" in lowered or "moderation" in lowered or "policy" in lowered):
error_type = ImageProviderErrorType.CONTENT_REJECTED
retryable = False
message = "图片内容未通过供应商审核,请调整提示词后重试"
elif status_code and 400 <= int(status_code) < 500:
error_type = ImageProviderErrorType.INVALID_REQUEST
retryable = False
message = "图片生成参数不被供应商支持,请联系管理员检查引擎配置"
elif isinstance(exc, httpx.HTTPError):
error_type = ImageProviderErrorType.NETWORK
retryable = True
message = "图片供应商网络连接异常,请稍后重试"
else:
error_type = ImageProviderErrorType.UNKNOWN
retryable = False
message = raw_message or "图片生成失败"
return ImageProviderError(
message,
error_type=error_type,
error_code=_safe_text(code, limit=128) or None,
retryable=retryable,
http_status=int(status_code) if status_code is not None else None,
provider_request_id=_safe_text(request_id, limit=128) or None,
)
def build_multi_image_provider_prompt(prompt: str, generation_count: int) -> str:
base_prompt = (prompt or "").strip()
if generation_count <= 1:
return base_prompt
suffix = MULTI_IMAGE_PROMPT_TEMPLATE.format(count=generation_count)
return f"{base_prompt}\n\n{suffix}" if base_prompt else suffix
def submit_image_task( def submit_image_task(
db, db,
engine: ProviderImageEngineLike, engine: ProviderImageEngineLike,
record: ProviderGenerationRecordLike, record: ProviderGenerationRecordLike,
*, *,
include_media_references: bool, include_media_references: bool,
) -> dict: generation_count: int = 1,
"""Submit an image generation task via Ark SDK. Returns image_url.""" ) -> ImageProviderBatchResult:
from volcenginesdkarkruntime import Ark """通过 Ark 同步图片接口生成单图或单次组图。
client = Ark(
base_url=engine.api_base,
api_key=engine.api_key,
timeout=300,
)
prompt = record.optimized_prompt or record.original_prompt generation_count > 1 时只执行一次 sequential_auto 请求任何失败都直接抛出
image_urls = [] 绝不退化为多次单图请求
"""
from volcenginesdkarkruntime import Ark
count = max(1, int(generation_count or 1))
multi_generation_enabled = bool(getattr(engine, "multi_generation_enabled", False))
max_generation_count = max(1, min(5, int(getattr(engine, "max_generation_count", 1) or 1)))
if count > 1 and not multi_generation_enabled:
raise ImageProviderError(
"当前图片引擎未开启多份生成",
error_type=ImageProviderErrorType.CAPABILITY_MISMATCH,
)
if count > max_generation_count:
raise ImageProviderError(
f"当前图片引擎最多允许生成 {max_generation_count}",
error_type=ImageProviderErrorType.CAPABILITY_MISMATCH,
)
client = Ark(base_url=engine.api_base, api_key=engine.api_key, timeout=300)
original_prompt = record.optimized_prompt or record.original_prompt
provider_prompt = build_multi_image_provider_prompt(original_prompt, count)
image_urls: list[str] = []
if include_media_references and record.media_references: if include_media_references and record.media_references:
try: try:
refs = json.loads(record.media_references) refs = json.loads(record.media_references)
for ref in refs: for ref in refs if isinstance(refs, list) else []:
ref_type = ref.get("type") if (ref.get("type") or "").lower() == "image" and ref.get("url"):
ref_url = ref.get("url", "") image_urls.append(_resolve_url(ref["url"]))
if ref_type == "image" and ref_url:
resolved = _resolve_url(ref_url)
image_urls.append(resolved)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass image_urls = []
request_payload = { request_log_payload: dict[str, Any] = {
"model": engine.model_name, "model": engine.model_name,
"prompt": prompt, "prompt": provider_prompt,
"size": record.image_size or engine.default_size, "size": record.image_size or engine.default_size,
"sequential_image_generation": "disabled",
"output_format": "png",
"response_format": "url", "response_format": "url",
"watermark": False, "watermark": False,
"include_media_references": include_media_references,
} }
request_sdk_payload: dict[str, Any] = dict(request_log_payload)
if image_urls: if image_urls:
request_payload["image"] = image_urls request_log_payload["image"] = image_urls
request_sdk_payload["image"] = image_urls
output_format = (getattr(engine, "output_format", "") or "").lower().strip()
if output_format:
request_log_payload["output_format"] = output_format
request_sdk_payload["output_format"] = output_format
if count > 1:
try:
from volcenginesdkarkruntime.types.images import SequentialImageGenerationOptions
except Exception:
try:
from volcenginesdkarkruntime.types.images.image_generate_params import (
SequentialImageGenerationOptions,
)
except Exception as import_exc:
raise ImageProviderError(
"当前图片引擎运行依赖缺少组图参数对象,请升级火山 Ark SDK 后重试",
error_type=ImageProviderErrorType.CAPABILITY_MISMATCH,
) from import_exc
_log_image_request(engine, record.id, request_payload) request_log_payload["sequential_image_generation"] = "auto"
request_log_payload["sequential_image_generation_options"] = {"max_images": count}
request_log_payload["stream"] = False
request_sdk_payload["sequential_image_generation"] = "auto"
request_sdk_payload["sequential_image_generation_options"] = SequentialImageGenerationOptions(
max_images=count,
)
request_sdk_payload["stream"] = False
_log_image_request(engine, record.id, request_log_payload)
try: try:
result = client.images.generate( result = client.images.generate(**request_sdk_payload)
model=engine.model_name, top_error = _value(result, "error")
prompt=prompt, if top_error:
size=record.image_size or engine.default_size, error_code = _value(top_error, "code")
output_format="png", error_message = _value(top_error, "message") or str(top_error)
response_format="url", raise ImageProviderError(
watermark=False, _safe_text(error_message) or "图片供应商返回失败",
image=image_urls if image_urls else None, error_type=ImageProviderErrorType.INVALID_REQUEST,
) error_code=_safe_text(error_code, limit=128) or None,
image_url = result.data[0].url )
response_data = { raw_data = _value(result, "data", []) or []
"model": result.model, if not isinstance(raw_data, (list, tuple)):
"created": result.created, raise ImageProviderError(
"data": [{"url": item.url, "size": item.size} for item in result.data] if result.data else [], "图片供应商返回 data 结构异常",
"usage": { error_type=ImageProviderErrorType.INVALID_RESPONSE,
"generated_images": result.usage.generated_images if hasattr(result.usage, 'generated_images') else 0, )
"output_tokens": result.usage.output_tokens if hasattr(result.usage, 'output_tokens') else 0,
"total_tokens": result.usage.total_tokens if hasattr(result.usage, 'total_tokens') else 0, items: list[ImageProviderItem] = []
response_items: list[dict[str, Any]] = []
for index, raw_item in enumerate(raw_data, start=1):
item_error = _value(raw_item, "error")
if item_error:
error_code = _safe_text(_value(item_error, "code"), limit=128)
error_message = _safe_text(_value(item_error, "message") or item_error)
items.append({
"generation_index": index,
"error_code": error_code,
"error_message": error_message or "单张图片生成失败",
"response_data": _jsonable(raw_item),
})
response_items.append(_jsonable(raw_item))
continue
url = _safe_text(_value(raw_item, "url"), limit=4000)
b64_json = _safe_text(_value(raw_item, "b64_json"), limit=100) if not url else ""
item: ImageProviderItem = {
"generation_index": index,
"remote_result_url": url,
"size": _safe_text(_value(raw_item, "size"), limit=64),
"output_format": _safe_text(_value(raw_item, "output_format"), limit=32),
"response_data": _jsonable(raw_item),
} }
if b64_json:
item["b64_json"] = b64_json
items.append(item)
response_items.append(_jsonable(raw_item))
usage = _value(result, "usage")
generated_images = int(_value(usage, "generated_images", 0) or 0)
total_tokens = int(_value(usage, "total_tokens", 0) or 0)
response_data = {
"model": _value(result, "model", engine.model_name),
"created": _value(result, "created"),
"data": response_items,
"usage": {
"generated_images": generated_images,
"input_images": int(_value(usage, "input_images", 0) or 0),
"output_tokens": int(_value(usage, "output_tokens", 0) or 0),
"total_tokens": total_tokens,
},
} }
except httpx.TimeoutException: _log_image_response(record.id, response_data)
error_msg = "图片生成超时,请稍后重试" return {
logger.error(f"Image generation timeout for record {record.id}") "items": items,
_log_image_response(record.id, {}, error_msg) "model": str(response_data["model"] or ""),
raise TimeoutError(error_msg) "created": int(response_data["created"] or 0),
except Exception as e: "generated_images": generated_images,
error_msg = str(e) "image_tokens": total_tokens,
logger.error(f"Image generation failed for record {record.id}: {error_msg}") "response_data": response_data,
_log_image_response(record.id, {}, error_msg) }
raise except Exception as exc:
provider_error = _classify_provider_exception(exc)
logger.error(
"Image generation failed for record %s: type=%s code=%s message=%s",
record.id,
provider_error.error_type.value,
provider_error.error_code,
provider_error.safe_message,
)
_log_image_response(record.id, provider_error.as_dict(), provider_error.safe_message)
raise provider_error from exc
finally: finally:
client.close() try:
client.close()
return { except Exception:
"image_url": image_url, pass
"image_tokens": getattr(result.usage, "total_tokens", 0),
"response_data": json.dumps(response_data, ensure_ascii=False, default=str),
"error": str(result.error) if result.error else "",
}
async def poll_image_task_status(engine: ImageEngine, task_id: str) -> dict: async def poll_image_task_status(engine: ImageEngine, task_id: str) -> dict:
"""Query image task status via Ark SDK. Returns {status, image_url, response_data}.""" client = AsyncArk(base_url=engine.api_base, api_key=engine.api_key)
client = AsyncArk( try:
base_url=engine.api_base, result = await client.image_generation.tasks.get(task_id=task_id)
api_key=engine.api_key, finally:
) await client.close()
result = await client.image_generation.tasks.get(task_id=task_id)
await client.close()
response_dict = { response_dict = {
"id": result.id, "id": result.id,
@@ -239,13 +456,11 @@ async def poll_image_task_status(engine: ImageEngine, task_id: str) -> dict:
async def download_image(image_url: str, dest_path: str) -> str: async def download_image(image_url: str, dest_path: str) -> str:
"""Download image to local storage."""
os.makedirs(os.path.dirname(dest_path), exist_ok=True) os.makedirs(os.path.dirname(dest_path), exist_ok=True)
async with httpx.AsyncClient(timeout=300) as client: async with httpx.AsyncClient(timeout=300) as client:
async with client.stream("GET", image_url) as response: async with client.stream("GET", image_url) as response:
response.raise_for_status() response.raise_for_status()
with open(dest_path, "wb") as f: with open(dest_path, "wb") as file:
async for chunk in response.aiter_bytes(chunk_size=8192): async for chunk in response.aiter_bytes(chunk_size=8192):
f.write(chunk) file.write(chunk)
return dest_path return dest_path
+60 -49
View File
@@ -248,7 +248,63 @@ async def _call_openai_compatible(
if ref_type == "video": if ref_type == "video":
video_urls.append(ref_url) video_urls.append(ref_url)
def _build_file_url_or_data_uri(file_url: str, fallback_mime: str) -> str: async def _build_multimodal_content(
user_content: str,
image_urls: list[str],
video_urls: list[str],
) -> tuple[dict, dict | None]:
"""构建多模态 user_message。返回 (actual_message, log_message)。"""
from app.utils.media import media_to_base64
content_parts = [{"type": "text", "text": user_content}]
from app.utils.media import get_llm_media_as_base64
as_base64 = await get_llm_media_as_base64(db)
for img in image_urls:
if as_base64:
url = await media_to_base64(img, "image/png")
else:
url = _file_url_or_data_uri(img, "image/png")
content_parts.append({
"type": "image_url",
"image_url": {"url": url},
})
for video in video_urls:
if as_base64:
url = await media_to_base64(video, "video/mp4")
else:
url = _file_url_or_data_uri(video, "video/mp4")
content_parts.append({
"type": "video_url",
"video_url": {"url": url},
})
user_message = {
"role": "user",
"content": content_parts,
}
# Log-friendly version: keep original paths instead of base64
log_content_parts = [{"type": "text", "text": user_content}]
for img in image_urls:
log_content_parts.append({
"type": "image_url",
"image_url": {"url": img},
})
for video in video_urls:
log_content_parts.append({
"type": "video_url",
"video_url": {"url": video},
})
log_user_message = {
"role": "user",
"content": log_content_parts,
}
return user_message, log_user_message
def _file_url_or_data_uri(file_url: str, fallback_mime: str) -> str:
""" """
Convert local upload path to base64 data URI. Convert local upload path to base64 data URI.
Keep remote http/https/data URLs as-is. Keep remote http/https/data URLs as-is.
@@ -268,54 +324,9 @@ async def _call_openai_compatible(
return f"data:{mime};base64,{b64}" return f"data:{mime};base64,{b64}"
if image_urls or video_urls: if image_urls or video_urls:
content_parts = [{"type": "text", "text": user_content}] user_message, log_user_message = await _build_multimodal_content(
user_content, image_urls, video_urls
for img in image_urls: )
url = _build_file_url_or_data_uri(img, "image/png")
content_parts.append({
"type": "image_url",
"image_url": {
"url": url,
},
})
for video in video_urls:
url = _build_file_url_or_data_uri(video, "video/mp4")
content_parts.append({
"type": "video_url",
"video_url": {
"url": url,
},
})
user_message = {
"role": "user",
"content": content_parts,
}
# Log-friendly version: keep original paths instead of base64
log_content_parts = [{"type": "text", "text": user_content}]
for img in image_urls:
log_content_parts.append({
"type": "image_url",
"image_url": {
"url": img,
},
})
for video in video_urls:
log_content_parts.append({
"type": "video_url",
"video_url": {
"url": video,
},
})
log_user_message = {
"role": "user",
"content": log_content_parts,
}
else: else:
user_message = { user_message = {
"role": "user", "role": "user",
@@ -242,6 +242,12 @@ ACTIVE_CHAT_TASK_BLOCK_STAGES = {
ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value, ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value,
ChatGenerationPipelineStage.DOWNLOADING.value, ChatGenerationPipelineStage.DOWNLOADING.value,
ChatGenerationPipelineStage.RETRY_WAITING.value, ChatGenerationPipelineStage.RETRY_WAITING.value,
ChatGenerationPipelineStage.UPSCALE_QUEUED.value,
ChatGenerationPipelineStage.UPSCALE_PROCESSING.value,
ChatGenerationPipelineStage.UPSCALE_POLLING.value,
ChatGenerationPipelineStage.UPSCALE_DOWNLOADING.value,
ChatGenerationPipelineStage.UPSCALE_FINALIZING.value,
ChatGenerationPipelineStage.UPSCALE_RETRY_WAITING.value,
} }
@@ -4,7 +4,7 @@ from collections.abc import Iterable
from datetime import datetime from datetime import datetime
from typing import Any, TypedDict from typing import Any, TypedDict
from sqlalchemy import and_, func, or_, select from sqlalchemy import and_, case, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.generation_task import GenerationType from app.enums.generation_task import GenerationType
@@ -12,7 +12,7 @@ from app.enums.recent_generation import (
RECENT_GENERATION_ALL_MODULES, RECENT_GENERATION_ALL_MODULES,
RECENT_GENERATION_CHAT_TASK_MODULES, RECENT_GENERATION_CHAT_TASK_MODULES,
RECENT_GENERATION_COMPLETED_STATUS, RECENT_GENERATION_COMPLETED_STATUS,
RECENT_GENERATION_MODULE_TO_TASK_MODE, RECENT_GENERATION_MODULE_TO_TASK_MODES,
RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE, RECENT_GENERATION_TASK_MODE_VALUE_TO_MODULE,
RecentGenerationModuleEnum, RecentGenerationModuleEnum,
RecentGenerationResourceTypeEnum, RecentGenerationResourceTypeEnum,
@@ -175,11 +175,12 @@ async def _list_chat_task_recent_rows(
modules: list[RecentGenerationModuleEnum], modules: list[RecentGenerationModuleEnum],
limit: int, limit: int,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
task_mode_values = [ task_mode_values = list(dict.fromkeys(
RECENT_GENERATION_MODULE_TO_TASK_MODE[module].value task_mode.value
for module in modules for module in modules
if module in RECENT_GENERATION_CHAT_TASK_MODULES if module in RECENT_GENERATION_CHAT_TASK_MODULES
] for task_mode in RECENT_GENERATION_MODULE_TO_TASK_MODES[module]
))
if not task_mode_values: if not task_mode_values:
return [] return []
@@ -189,10 +190,19 @@ async def _list_chat_task_recent_rows(
ChatGenerationTask.created_at, ChatGenerationTask.created_at,
) )
module_partition_expr = case(
(
ChatGenerationTask.generation_mode.in_(["chatapi_async", "chatapi_child"]),
RecentGenerationModuleEnum.CHAT_AI.value,
),
else_=ChatGenerationTask.generation_mode,
)
ranked_subquery = ( ranked_subquery = (
select( select(
ChatGenerationTask.id.label("generation_id"), ChatGenerationTask.id.label("generation_id"),
ChatGenerationTask.generation_mode.label("generation_mode"), ChatGenerationTask.generation_mode.label("generation_mode"),
module_partition_expr.label("module_key"),
ChatGenerationTask.gen_type.label("gen_type"), ChatGenerationTask.gen_type.label("gen_type"),
ChatGenerationTask.image_url.label("image_url"), ChatGenerationTask.image_url.label("image_url"),
ChatGenerationTask.video_url.label("video_url"), ChatGenerationTask.video_url.label("video_url"),
@@ -200,7 +210,7 @@ async def _list_chat_task_recent_rows(
generated_time_expr.label("generated_time"), generated_time_expr.label("generated_time"),
func.row_number() func.row_number()
.over( .over(
partition_by=ChatGenerationTask.generation_mode, partition_by=module_partition_expr,
order_by=(generated_time_expr.desc(), ChatGenerationTask.created_at.desc()), order_by=(generated_time_expr.desc(), ChatGenerationTask.created_at.desc()),
) )
.label("row_num"), .label("row_num"),
@@ -218,7 +228,7 @@ async def _list_chat_task_recent_rows(
stmt = ( stmt = (
select(ranked_subquery) select(ranked_subquery)
.where(ranked_subquery.c.row_num <= limit) .where(ranked_subquery.c.row_num <= limit)
.order_by(ranked_subquery.c.generation_mode.asc(), ranked_subquery.c.generated_time.desc()) .order_by(ranked_subquery.c.module_key.asc(), ranked_subquery.c.generated_time.desc())
) )
return [dict(row) for row in (await db.execute(stmt)).mappings().all()] return [dict(row) for row in (await db.execute(stmt)).mappings().all()]
@@ -1,14 +1,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
from copy import deepcopy from datetime import datetime
from datetime import datetime, timezone
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from app.config import settings from app.config import settings
from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum from app.enums.common import ModuleEventTypeEnum, ModuleProjectStatusEnum, ModulePromptTypeEnum, ModuleStepStatusEnum
@@ -35,16 +33,16 @@ from app.schemas.shot_replicate import (
ShotReplicateVideoGenerationOut, ShotReplicateVideoGenerationOut,
ShotReplicateVideoPromptSchemaUpdateRequest, ShotReplicateVideoPromptSchemaUpdateRequest,
) )
from app.services.generation_ai_service import ( from app.services.generation.ai.engine_service import (
VIDEO_DEFAULT_DURATION, VIDEO_DEFAULT_DURATION,
VIDEO_DEFAULT_RATIO, VIDEO_DEFAULT_RATIO,
VIDEO_DEFAULT_RESOLUTION, VIDEO_DEFAULT_RESOLUTION,
_get_video_engine, get_video_engine,
_parse_list, parse_json_list,
) )
from app.services.generation_billing_service import charge_module_prompt_usage from app.services.generation.billing_service import charge_module_prompt_usage
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation.refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.generation_task_factory_service import create_chat_generation_task_for_module from app.services.generation.task_factory_service import create_chat_generation_task_for_module
from app.services.hot_opening_video_prompt_service import ( from app.services.hot_opening_video_prompt_service import (
build_final_video_prompt, build_final_video_prompt,
optimize_hot_opening_video_prompt as optimize_shot_replicate_video_prompt, optimize_hot_opening_video_prompt as optimize_shot_replicate_video_prompt,
@@ -52,7 +50,6 @@ from app.services.hot_opening_video_prompt_service import (
) )
from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event from app.services.module_generation_log_service import log_module_error, log_module_event_file, log_module_prompt_event
from app.services.llm import optimize_prompt from app.services.llm import optimize_prompt
from app.services.resource_accounting_service import soft_delete_chat_task_resources
from app.services.module_generation_flow_base_service import ( from app.services.module_generation_flow_base_service import (
assert_project_has_no_active_chat_tasks as _base_assert_project_has_no_active_chat_tasks, assert_project_has_no_active_chat_tasks as _base_assert_project_has_no_active_chat_tasks,
chat_tasks_by_id as _base_chat_tasks_by_id, chat_tasks_by_id as _base_chat_tasks_by_id,
@@ -976,10 +973,10 @@ async def generate_image_from_prompt(
async def _resolve_video_prompt_config(db: AsyncSession, req: ShotReplicateGenerateVideoPromptRequest) -> dict[str, Any]: async def _resolve_video_prompt_config(db: AsyncSession, req: ShotReplicateGenerateVideoPromptRequest) -> dict[str, Any]:
engine = await _get_video_engine(db, req.engine_id) engine = await get_video_engine(db, req.engine_id)
supported_ratios = _parse_list(engine.supported_ratios, []) supported_ratios = parse_json_list(engine.supported_ratios, [])
supported_resolutions = _parse_list(engine.supported_resolutions, []) supported_resolutions = parse_json_list(engine.supported_resolutions, [])
supported_durations = _parse_list(engine.supported_durations, []) supported_durations = parse_json_list(engine.supported_durations, [])
default_ratio = getattr(settings, "SHOT_REPLICATE_DEFAULT_VIDEO_RATIO", None) or VIDEO_DEFAULT_RATIO default_ratio = getattr(settings, "SHOT_REPLICATE_DEFAULT_VIDEO_RATIO", None) or VIDEO_DEFAULT_RATIO
default_resolution = getattr(settings, "SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION", None) or VIDEO_DEFAULT_RESOLUTION default_resolution = getattr(settings, "SHOT_REPLICATE_DEFAULT_VIDEO_RESOLUTION", None) or VIDEO_DEFAULT_RESOLUTION
@@ -77,8 +77,12 @@ def build_file_url_or_data_uri(file_url: str, fallback_mime: str = "video/mp4")
# return f"data:{mime};base64,{b64}" # return f"data:{mime};base64,{b64}"
def build_user_message(user_text: str, video_url: str) -> tuple[dict[str, Any], dict[str, Any], str]: async def build_user_message(user_text: str, video_url: str, db=None) -> tuple[dict[str, Any], dict[str, Any], str]:
real_url = build_file_url_or_data_uri(video_url) from app.utils.media import media_to_base64, get_llm_media_as_base64
if await get_llm_media_as_base64(db):
real_url = await media_to_base64(video_url, "video/mp4")
else:
real_url = build_file_url_or_data_uri(video_url)
content_parts = [ content_parts = [
{ {
"type": "video_url", "type": "video_url",
@@ -543,7 +547,7 @@ async def analyze_video_for_shot_split(
system_prompt = build_video_analysis_system_prompt(mode=mode) system_prompt = build_video_analysis_system_prompt(mode=mode)
user_text = build_video_analysis_user_text(mode=mode) user_text = build_video_analysis_user_text(mode=mode)
user_message, log_user_message, real_video_url = build_user_message(user_text, video_url) user_message, log_user_message, real_video_url = await build_user_message(user_text, video_url, db)
request_data: dict[str, Any] = { request_data: dict[str, Any] = {
"model": config.model_name, "model": config.model_name,
+2 -2
View File
@@ -14,7 +14,7 @@ from app.config import settings
from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX from app.enums.private_portrait import PRIVATE_PORTRAIT_ASSET_URI_PREFIX
from app.models.video_engine import VideoEngine from app.models.video_engine import VideoEngine
from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data from app.services.log_config import is_enabled, LOG_DIR, LOG_DATE_FORMAT, encrypt_data
from app.services.generation_provider_types import ( from app.types.generation.provider import (
ProviderGenerationRecordLike, ProviderGenerationRecordLike,
ProviderVideoEngineLike, ProviderVideoEngineLike,
) )
@@ -148,7 +148,7 @@ async def submit_video_task(
"content": content, "content": content,
"ratio": record.aspect_ratio, "ratio": record.aspect_ratio,
"duration": record.duration, "duration": record.duration,
"resolution": record.resolution, "resolution": getattr(record, "provider_generation_resolution", None) or record.resolution,
"generate_audio": True, "generate_audio": True,
"watermark": False, "watermark": False,
} }
+297 -144
View File
@@ -2,123 +2,267 @@ import asyncio
import json import json
import logging import logging
import os import os
from datetime import datetime from datetime import datetime, timezone
from urllib.parse import urlparse
from sqlalchemy import select from sqlalchemy import or_, select
from app.config import settings
from app.enums.generation_status import GenerationRecordPipelineStage
from app.models.base import async_session from app.models.base import async_session
from app.models.generation_record import GenerationRecord from app.models.generation_record import GenerationRecord
from app.services.video_gen import get_active_engine, poll_task_status, download_video, _log_video_response from app.services.generation.refund_service import mark_generation_record_failed_and_refund_once
from app.services.image_gen import get_active_image_engine, download_image from app.services.image_gen import download_image, get_active_image_engine
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot
from app.services.provider_limit import provider_limit
from app.services.resource_accounting_service import ( from app.services.resource_accounting_service import (
record_generation_record_generated_resource, record_generation_record_generated_resource,
safe_file_size, safe_file_size,
) )
from app.services.video_cover_service import create_video_cover_for_local_video from app.services.video_cover_service import create_video_cover_for_local_video
from app.config import settings from app.services.video_gen import _log_video_response, download_video, get_active_engine, poll_task_status
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once from app.services.video_upscale.media_service import build_part_mp4_path, probe_video, safe_remove
logger = logging.getLogger("videogen") logger = logging.getLogger("videogen")
POLL_INTERVAL = 30 # seconds between polls POLL_INTERVAL = 30
MAX_POLLS = 60 # max 30 minutes total MAX_POLLS = 60
_PROVIDER_RECOVERABLE_STAGES = {
None,
"",
GenerationRecordPipelineStage.CREATING_PROVIDER_TASK.value,
GenerationRecordPipelineStage.WAITING_REMOTE.value,
GenerationRecordPipelineStage.POLLING.value,
GenerationRecordPipelineStage.RESULT_READY.value,
GenerationRecordPipelineStage.DOWNLOADING.value,
}
def _is_provider_stage(record: GenerationRecord) -> bool:
return (record.pipeline_stage or "") in _PROVIDER_RECOVERABLE_STAGES
def _source_date_dir(record: GenerationRecord) -> str:
created = record.created_at
if created is None:
created = datetime.now(timezone.utc)
return created.strftime("%Y/%m/%d")
def _normalize_image_extension(output_format: str | None, remote_url: str | None = None) -> str:
value = str(output_format or "").strip().lower()
if value in {"jpg", "jpeg"}:
return "jpg"
if value == "png":
return "png"
if value == "webp":
return "webp"
if remote_url:
try:
path = urlparse(remote_url).path or ""
except Exception:
path = str(remote_url)
suffix = os.path.splitext(path)[1].lower().lstrip(".")
if suffix in {"jpg", "jpeg"}:
return "jpg"
if suffix == "png":
return "png"
if suffix == "webp":
return "webp"
return "jpg"
async def _download_generation_record_upscale_source(
record: GenerationRecord,
remote_url: str,
) -> tuple[str, int]:
date_dir = _source_date_dir(record)
dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, "_upscale_source", date_dir)
os.makedirs(dest_dir, exist_ok=True)
final_path = os.path.join(dest_dir, f"{record.id}.source.mp4")
if os.path.isfile(final_path) and os.path.getsize(final_path) > 0:
await probe_video(final_path)
return final_path, safe_file_size(final_path)
part_path = build_part_mp4_path(final_path)
try:
async with provider_limit("result_download", settings.RESULT_DOWNLOAD_MAX_CONCURRENCY):
await download_video(remote_url, part_path)
if not os.path.isfile(part_path) or os.path.getsize(part_path) <= 0:
raise RuntimeError("超分源视频下载完成但临时文件为空")
await probe_video(part_path)
os.replace(part_path, final_path)
return final_path, safe_file_size(final_path)
except Exception:
safe_remove(part_path)
raise
async def handle_generation_record_video_succeeded(
db,
record: GenerationRecord,
*,
remote_url: str,
provider_response: dict | None,
video_tokens: int = 0,
) -> bool:
"""处理 GenerationRecord 原视频生成成功。
返回 True 表示已进入超分队列False 表示按原流程直接完成
"""
record.video_tokens_used = int(video_tokens or 0)
await sync_generation_record_media_token_snapshot(db, record, provider_response=provider_response or {})
if bool(record.video_upscale_enabled_snapshot) and record.video_upscale_snapshot_json:
record.pipeline_stage = GenerationRecordPipelineStage.DOWNLOADING.value
await db.flush()
source_path, source_size = await _download_generation_record_upscale_source(record, remote_url)
from app.services.video_upscale.task_service import enqueue_upscale_task, prepare_video_upscale_task
upscale = await prepare_video_upscale_task(
db,
generation_record=record,
source_local_path=source_path,
source_file_size_bytes=source_size,
source_remote_url=remote_url,
)
# enqueue_upscale_task 会先提交数据库,再投递 Celery;投递失败由 gen_recovery 补投。
await enqueue_upscale_task(db, upscale=upscale, reason="generation_record_source_ready")
logger.info("GenerationRecord 已进入视频超分队列: record_id=%s upscale_task_id=%s", record.id, upscale.id)
return True
storage_path = None
file_size_bytes = 0
if settings.STORAGE_TYPE == "local" and remote_url:
try:
date_dir = _source_date_dir(record)
dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, date_dir)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, f"{record.id}.mp4")
await download_video(remote_url, dest)
record.video_url = f"/generate/videos/{date_dir}/{record.id}.mp4"
cover_url, _cover_storage_path = create_video_cover_for_local_video(
record_id=record.id,
video_path=dest,
date_dir=date_dir,
log_prefix=f"GenerationRecord视频封面生成 record_id={record.id}",
)
record.video_cover_url = cover_url
storage_path = dest
file_size_bytes = safe_file_size(dest)
except Exception as exc:
logger.warning("GenerationRecord 最终视频本地保存失败,回退远程地址: record_id=%s error=%s", record.id, exc)
record.video_url = remote_url
else:
record.video_url = remote_url
record.status = "completed"
record.pipeline_stage = GenerationRecordPipelineStage.DONE.value
record.generated_at = datetime.now(timezone.utc)
record.error_message = None
if record.video_url:
await record_generation_record_generated_resource(
db,
record,
resource_url=record.video_url,
storage_path=storage_path,
file_size_bytes=file_size_bytes,
remote_url=remote_url,
generated_at=record.generated_at,
)
await db.commit()
return False
class TaskQueue: class TaskQueue:
def __init__(self): def __init__(self):
self.queue: asyncio.Queue[str] = asyncio.Queue() self.queue: asyncio.Queue[str] = asyncio.Queue()
self.running = False self.running = False
self._active: dict[str, int] = {} # record_id -> poll count self._active: dict[str, int] = {}
async def enqueue(self, record_id: str): async def enqueue(self, record_id: str):
"""Add a record to the polling queue."""
await self.queue.put(record_id) await self.queue.put(record_id)
async def recover(self): async def recover(self):
"""Recover in-progress tasks from DB on startup."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(GenerationRecord).where( select(GenerationRecord).where(
GenerationRecord.status == "generating", GenerationRecord.status == "generating",
GenerationRecord.seedance_task_id.isnot(None), GenerationRecord.seedance_task_id.isnot(None),
GenerationRecord.deleted_at.is_(None), GenerationRecord.deleted_at.is_(None),
or_(
GenerationRecord.pipeline_stage.is_(None),
GenerationRecord.pipeline_stage.in_(
[stage for stage in _PROVIDER_RECOVERABLE_STAGES if stage]
),
),
) )
) )
records = result.scalars().all() records = result.scalars().all()
for record in records: for record in records:
await self.queue.put(record.id) await self.queue.put(record.id)
logger.info(f"Recovered task: {record.id} (seedance: {record.seedance_task_id})") logger.info("Recovered task: %s (seedance: %s stage=%s)", record.id, record.seedance_task_id, record.pipeline_stage)
async def run(self): async def run(self):
"""Main polling loop."""
self.running = True self.running = True
logger.info("Video queue started") logger.info("Video queue started")
while self.running: while self.running:
try: try:
record_id = await asyncio.wait_for(self.queue.get(), timeout=5.0) record_id = await asyncio.wait_for(self.queue.get(), timeout=5.0)
except asyncio.TimeoutError: except asyncio.TimeoutError:
continue continue
try: try:
await self._process(record_id) await self._process(record_id)
except Exception as e: except Exception as exc:
logger.error(f"Error processing {record_id}: {e}") logger.exception("Error processing %s: %s", record_id, exc)
finally: finally:
self.queue.task_done() self.queue.task_done()
logger.info("Video queue stopped") logger.info("Video queue stopped")
async def _process(self, record_id: str): async def _process(self, record_id: str):
"""Process a single record: poll status and update DB."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(GenerationRecord).where( select(GenerationRecord).where(
GenerationRecord.id == record_id, GenerationRecord.id == record_id,
GenerationRecord.deleted_at.is_(None), GenerationRecord.deleted_at.is_(None),
) ).with_for_update().limit(1)
.with_for_update()
.limit(1)
) )
record = result.scalar_one_or_none() record = result.scalar_one_or_none()
if not record or record.status != "generating": if not record or record.status != "generating":
return return
if record.gen_type == "video": if record.gen_type == "video":
if not _is_provider_stage(record):
return
await self._process_video(db, record) await self._process_video(db, record)
else: else:
await self._process_image(db, record) await self._process_image(db, record)
async def _process_video(self, db, record): async def _process_video(self, db, record: GenerationRecord):
"""Process video generation task."""
record_id = record.id record_id = record.id
if not record.seedance_task_id: if not record.seedance_task_id:
await mark_generation_record_failed_and_refund_once( record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
db, await mark_generation_record_failed_and_refund_once(db, record=record, error_message="缺少外部任务ID")
record=record,
error_message="缺少外部任务ID",
)
await db.commit() await db.commit()
return return
record.pipeline_stage = GenerationRecordPipelineStage.POLLING.value
try: try:
engine = await get_active_engine(db) engine = await get_active_engine(db)
poll_result = await poll_task_status(engine, record.seedance_task_id) poll_result = await poll_task_status(engine, record.seedance_task_id)
except Exception as e: except Exception as exc:
logger.error(f"Poll error for {record_id}: {e}") logger.error("Poll error for %s: %s", record_id, exc)
count = self._active.get(record_id, 0) + 1 count = self._active.get(record_id, 0) + 1
self._active[record_id] = count self._active[record_id] = count
if count >= MAX_POLLS: if count >= MAX_POLLS:
await mark_generation_record_failed_and_refund_once( record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
db, await mark_generation_record_failed_and_refund_once(db, record=record, error_message=f"轮询超时: {exc}")
record=record,
error_message=f"轮询超时: {e}",
)
await db.commit() await db.commit()
del self._active[record_id] self._active.pop(record_id, None)
else: else:
await db.commit()
await asyncio.sleep(POLL_INTERVAL) await asyncio.sleep(POLL_INTERVAL)
await self.queue.put(record_id) await self.queue.put(record_id)
return return
@@ -128,54 +272,45 @@ class TaskQueue:
resp_data = json.loads(poll_result.get("response_data", "{}")) resp_data = json.loads(poll_result.get("response_data", "{}"))
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
resp_data = {} resp_data = {}
_log_video_response(record_id, resp_data, poll_result.get("error")) _log_video_response(record_id, resp_data, poll_result.get("error"))
if status == "succeeded": if status == "succeeded":
file_url = poll_result.get("video_url", "") file_url = str(poll_result.get("video_url") or "").strip()
storage_path = None if not file_url:
file_size_bytes = 0 record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
if settings.STORAGE_TYPE == "local" and file_url: await mark_generation_record_failed_and_refund_once(db, record=record, error_message="供应商成功但未返回视频地址")
try: await db.commit()
date_dir = datetime.now().strftime("%Y/%m/%d") return
dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, date_dir) record.pipeline_stage = GenerationRecordPipelineStage.RESULT_READY.value
os.makedirs(dest_dir, exist_ok=True) await db.flush()
dest = os.path.join(dest_dir, f"{record_id}.mp4") try:
await download_video(file_url, dest) await handle_generation_record_video_succeeded(
record.video_url = f"/generate/videos/{date_dir}/{record_id}.mp4"
cover_url, _cover_storage_path = create_video_cover_for_local_video(
record_id=record_id,
video_path=dest,
date_dir=date_dir,
log_prefix=f"GenerationRecord视频封面生成 record_id={record_id}",
)
record.video_cover_url = cover_url
storage_path = dest
file_size_bytes = safe_file_size(dest)
except Exception as e:
logger.warning(f"Download failed, using remote URL: {e}")
record.video_url = file_url
else:
record.video_url = file_url
record.video_tokens_used = poll_result.get("video_tokens", 0)
await sync_generation_record_media_token_snapshot(db, record, provider_response=resp_data)
record.status = "completed"
record.generated_at = datetime.now()
if record.video_url:
await record_generation_record_generated_resource(
db, db,
record, record,
resource_url=record.video_url,
storage_path=storage_path,
file_size_bytes=file_size_bytes,
remote_url=file_url, remote_url=file_url,
generated_at=record.generated_at, provider_response=resp_data,
video_tokens=poll_result.get("video_tokens", 0),
) )
except Exception as exc:
await db.rollback()
result = await db.execute(
select(GenerationRecord).where(GenerationRecord.id == record_id).with_for_update().limit(1)
)
failed_record = result.scalar_one_or_none()
if failed_record:
failed_record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once(
db,
record=failed_record,
error_message=f"视频结果下载失败: {exc}",
)
await db.commit()
logger.exception("GenerationRecord 视频成功结果处理失败: %s", record_id)
self._active.pop(record_id, None) self._active.pop(record_id, None)
await db.commit() return
logger.info(f"Video task completed: {record_id}")
elif status == "failed": if status == "failed":
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once( await mark_generation_record_failed_and_refund_once(
db, db,
record=record, record=record,
@@ -183,29 +318,26 @@ class TaskQueue:
) )
self._active.pop(record_id, None) self._active.pop(record_id, None)
await db.commit() await db.commit()
logger.info(f"Video task failed: {record_id}") logger.info("Video task failed: %s", record_id)
return
count = self._active.get(record_id, 0) + 1
self._active[record_id] = count
if count >= MAX_POLLS:
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once(db, record=record, error_message="视频生成超时")
self._active.pop(record_id, None)
await db.commit()
logger.info("Video task timed out: %s", record_id)
else: else:
count = self._active.get(record_id, 0) + 1 await db.commit()
self._active[record_id] = count await asyncio.sleep(POLL_INTERVAL)
if count >= MAX_POLLS: await self.queue.put(record_id)
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message="视频生成超时",
)
self._active.pop(record_id, None)
await db.commit()
logger.info(f"Video task timed out: {record_id}")
else:
await db.commit()
await asyncio.sleep(POLL_INTERVAL)
await self.queue.put(record_id)
async def _process_image(self, db, record): async def _process_image(self, db, record: GenerationRecord):
"""Process image generation task - calls API directly.""" """Process image generation task - calls API directly."""
record_id = record.id record_id = record.id
from app.services.image_gen import submit_image_task, _log_image_response from app.services.image_gen import submit_image_task
try: try:
engine = await get_active_image_engine(db) engine = await get_active_image_engine(db)
@@ -217,60 +349,81 @@ class TaskQueue:
include_media_references=False, include_media_references=False,
) )
if poll_result["error"] == "": if not isinstance(poll_result, dict):
remote_url = poll_result.get("image_url") raise RuntimeError("图片供应商返回结构异常")
storage_path = None
file_size_bytes = 0
if settings.STORAGE_TYPE == "local" and remote_url:
try:
date_dir = datetime.now().strftime("%Y/%m/%d")
dest_dir = os.path.join(settings.STORAGE_IMAGE_LOCAL_PATH, date_dir)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, f"{record_id}.png")
await download_image(remote_url, dest)
record.image_url = f"/generate/images/{date_dir}/{record_id}.png"
storage_path = dest
file_size_bytes = safe_file_size(dest)
except Exception as e:
logger.warning(f"Download failed, using remote URL: {e}")
record.image_url = remote_url
else:
record.image_url = remote_url
record.image_tokens_used = poll_result.get("image_tokens", 0)
await sync_generation_record_media_token_snapshot(db, record, provider_response=poll_result)
record.status = "completed"
record.generated_at = datetime.now()
if record.image_url:
await record_generation_record_generated_resource(
db,
record,
resource_url=record.image_url,
storage_path=storage_path,
file_size_bytes=file_size_bytes,
remote_url=remote_url,
generated_at=record.generated_at,
)
await db.commit()
logger.info(f"Image task completed: {record_id}")
else:
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message=poll_result.get("error", "图片生成失败"),
)
await db.commit()
logger.info(f"Image task failed: {record_id}")
_log_image_response(record_id, poll_result)
except Exception as e: items = poll_result.get("items") or []
if not isinstance(items, list):
raise RuntimeError("图片供应商返回结果列表异常")
if not items:
raise RuntimeError("图片供应商未返回图片结果")
if len(items) != 1:
raise RuntimeError(f"图片供应商单图返回数量异常,期望 1,实际 {len(items)}")
item = items[0] or {}
if not isinstance(item, dict):
raise RuntimeError("图片供应商返回单项结果结构异常")
item_error = item.get("error_message") or item.get("error_code")
if item_error:
raise RuntimeError(str(item_error))
remote_url = str(item.get("remote_result_url") or "").strip()
if not remote_url:
raise RuntimeError("图片供应商成功响应但没有图片地址")
storage_path = None
file_size_bytes = 0
if settings.STORAGE_TYPE == "local":
try:
date_dir = _source_date_dir(record)
dest_dir = os.path.join(settings.STORAGE_IMAGE_LOCAL_PATH, date_dir)
os.makedirs(dest_dir, exist_ok=True)
extension = _normalize_image_extension(item.get("output_format"), remote_url)
dest = os.path.join(dest_dir, f"{record_id}.{extension}")
await download_image(remote_url, dest)
record.image_url = f"/generate/images/{date_dir}/{record_id}.{extension}"
storage_path = dest
file_size_bytes = safe_file_size(dest)
except Exception as exc:
logger.warning("GenerationRecord 图片本地保存失败,回退远程地址: record_id=%s error=%s", record_id, exc)
record.image_url = remote_url
else:
record.image_url = remote_url
record.image_tokens_used = int(poll_result.get("image_tokens", 0) or 0)
provider_response = poll_result.get("response_data") or {}
await sync_generation_record_media_token_snapshot(
db,
record,
provider_response=provider_response if isinstance(provider_response, dict) else {},
)
record.status = "completed"
record.pipeline_stage = GenerationRecordPipelineStage.DONE.value
record.generated_at = datetime.now(timezone.utc)
record.error_message = None
if record.image_url:
await record_generation_record_generated_resource(
db,
record,
resource_url=record.image_url,
storage_path=storage_path,
file_size_bytes=file_size_bytes,
remote_url=remote_url,
generated_at=record.generated_at,
)
await db.commit()
logger.info("Image task completed: %s", record_id)
except Exception as exc:
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once( await mark_generation_record_failed_and_refund_once(
db, db,
record=record, record=record,
error_message=str(e), error_message=(getattr(exc, "safe_message", None) or str(exc) or "图片生成失败"),
) )
_log_image_response(record_id, {}, str(e))
await db.commit() await db.commit()
logger.error(f"Image task failed: {record_id}, error: {e}") logger.error("Image task failed: %s, error: %s", record_id, exc, exc_info=True)
def stop(self): def stop(self):
"""Signal the queue to stop.""" """Signal the queue to stop."""
@@ -0,0 +1,175 @@
from __future__ import annotations
import json
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.video_upscale import (
ALL_PROCESSOR_KEYS,
VIDEO_UPSCALE_CONFIG_DESCRIPTION,
VIDEO_UPSCALE_CONFIG_KEY,
VIDEO_UPSCALE_CONFIG_VERSION,
VIDEO_UPSCALE_RESOLUTION_RANK,
VIDEO_UPSCALE_RESOLUTIONS,
VideoUpscaleProcessorKey,
normalize_video_upscale_resolution,
)
from app.models.system_config import SystemConfig
from app.schemas.video_upscale import VideoUpscaleConfigData
from app.utils.id_gen import generate_id
def default_video_upscale_config() -> dict[str, Any]:
return {
"enabled": False,
"version": VIDEO_UPSCALE_CONFIG_VERSION,
"delete_source_after_success": True,
"rules": [],
}
def _dump(data: dict[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":"), default=str)
def _load(value: str | None) -> dict[str, Any]:
if not value:
return default_video_upscale_config()
try:
data = json.loads(value)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"视频超分配置 JSON 损坏: {exc}") from exc
if not isinstance(data, dict):
raise HTTPException(status_code=500, detail="视频超分配置必须是 JSON 对象")
return data
def _simplify_stored_config(data: dict[str, Any]) -> dict[str, Any]:
"""兼容开发阶段已保存的旧页面配置,忽略 processors、比例和手工像素等多余字段。"""
rules: list[dict[str, Any]] = []
for raw_rule in data.get("rules") or []:
if not isinstance(raw_rule, dict):
continue
rules.append(
{
"target_resolution": raw_rule.get("target_resolution"),
"provider_generation_resolution": raw_rule.get("provider_generation_resolution"),
"processor_key": raw_rule.get("processor_key"),
"enabled": bool(raw_rule.get("enabled", True)),
}
)
return {
"enabled": bool(data.get("enabled", False)),
"version": max(1, int(data.get("version") or VIDEO_UPSCALE_CONFIG_VERSION)),
"delete_source_after_success": bool(data.get("delete_source_after_success", True)),
"rules": rules,
}
async def _get_record(db: AsyncSession) -> SystemConfig | None:
result = await db.execute(select(SystemConfig).where(SystemConfig.key == VIDEO_UPSCALE_CONFIG_KEY).limit(1))
return result.scalar_one_or_none()
def validate_video_upscale_config(data: dict[str, Any] | VideoUpscaleConfigData) -> dict[str, Any]:
raw = data.model_dump() if isinstance(data, VideoUpscaleConfigData) else _simplify_stored_config(dict(data))
try:
model = VideoUpscaleConfigData.model_validate(raw)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
normalized = model.model_dump()
seen: set[str] = set()
for rule in normalized.get("rules") or []:
target = normalize_video_upscale_resolution(rule.get("target_resolution"))
provider_resolution = normalize_video_upscale_resolution(rule.get("provider_generation_resolution"))
processor_key = str(rule.get("processor_key") or "").strip()
if target not in VIDEO_UPSCALE_RESOLUTIONS:
raise HTTPException(
status_code=400,
detail=f"不支持的客户目标分辨率: {target},仅支持 {''.join(VIDEO_UPSCALE_RESOLUTIONS)}",
)
if provider_resolution not in VIDEO_UPSCALE_RESOLUTIONS:
raise HTTPException(
status_code=400,
detail=f"不支持的实际生成分辨率: {provider_resolution},仅支持 {''.join(VIDEO_UPSCALE_RESOLUTIONS)}",
)
if processor_key not in ALL_PROCESSOR_KEYS:
raise HTTPException(status_code=400, detail=f"未注册的超分处理器: {processor_key}")
rule["target_resolution"] = target
rule["provider_generation_resolution"] = provider_resolution
rule["processor_key"] = processor_key
if VIDEO_UPSCALE_RESOLUTION_RANK[provider_resolution] > VIDEO_UPSCALE_RESOLUTION_RANK[target]:
raise HTTPException(
status_code=400,
detail=f"规则 {target} 的实际生成分辨率 {provider_resolution} 不能高于客户目标分辨率",
)
if processor_key == VideoUpscaleProcessorKey.VOLC_LARGE_MODEL_V1.value:
if target not in {"720p", "1080p", "2K"}:
raise HTTPException(status_code=400, detail="火山画质增强大模型目标分辨率仅支持 720p、1080p、2K")
if VIDEO_UPSCALE_RESOLUTION_RANK[provider_resolution] > VIDEO_UPSCALE_RESOLUTION_RANK["1080p"]:
raise HTTPException(status_code=400, detail="火山画质增强大模型输入视频最高支持 1080p")
if processor_key in {
VideoUpscaleProcessorKey.VOLC_STANDARD_V1.value,
VideoUpscaleProcessorKey.VOLC_PROFESSIONAL_V1.value,
} and VIDEO_UPSCALE_RESOLUTION_RANK[provider_resolution] > VIDEO_UPSCALE_RESOLUTION_RANK["2K"]:
raise HTTPException(status_code=400, detail="火山标准版/专业版输入视频最高支持 2K")
if rule.get("enabled"):
if target in seen:
raise HTTPException(status_code=400, detail=f"客户目标分辨率存在重复启用规则: {target}")
seen.add(target)
return normalized
async def get_video_upscale_config(db: AsyncSession) -> dict[str, Any]:
record = await _get_record(db)
data = default_video_upscale_config() if record is None else validate_video_upscale_config(_load(record.value))
return {
"id": record.id if record else None,
"key": VIDEO_UPSCALE_CONFIG_KEY,
"description": record.description if record else VIDEO_UPSCALE_CONFIG_DESCRIPTION,
"data": data,
"created_at": record.created_at if record else None,
"updated_at": record.updated_at if record else None,
}
async def get_runtime_video_upscale_config(db: AsyncSession) -> dict[str, Any]:
record = await _get_record(db)
if record is None:
return default_video_upscale_config()
return validate_video_upscale_config(_load(record.value))
async def save_video_upscale_config(db: AsyncSession, data: dict[str, Any] | VideoUpscaleConfigData) -> dict[str, Any]:
normalized = validate_video_upscale_config(data)
record = await _get_record(db)
old_version = 0
if record:
try:
old_version = int((_load(record.value).get("version") or 0))
except Exception:
old_version = 0
normalized["version"] = max(old_version + 1, VIDEO_UPSCALE_CONFIG_VERSION)
if record is None:
record = SystemConfig(
id=generate_id(),
key=VIDEO_UPSCALE_CONFIG_KEY,
value=_dump(normalized),
description=VIDEO_UPSCALE_CONFIG_DESCRIPTION,
)
db.add(record)
else:
record.value = _dump(normalized)
record.description = VIDEO_UPSCALE_CONFIG_DESCRIPTION
await db.flush()
return await get_video_upscale_config(db)
@@ -0,0 +1,61 @@
from __future__ import annotations
import os
from collections.abc import Iterable
from fastapi import HTTPException
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.video_upscale import VideoUpscaleTaskStatus
from app.models.video_upscale_task import VideoUpscaleTask
async def assert_no_recoverable_failed_upscale_tasks(
db: AsyncSession,
chat_task_ids: Iterable[str] | None = None,
*,
generation_record_ids: Iterable[str] | None = None,
) -> None:
"""阻止删除仍保留本地源视频、可由管理员人工恢复的超分失败任务。"""
chat_ids = list(dict.fromkeys(str(item) for item in (chat_task_ids or []) if item))
record_ids = list(dict.fromkeys(str(item) for item in (generation_record_ids or []) if item))
if not chat_ids and not record_ids:
return
owner_conditions = []
if chat_ids:
owner_conditions.append(VideoUpscaleTask.chat_generation_task_id.in_(chat_ids))
if record_ids:
owner_conditions.append(VideoUpscaleTask.generation_record_id.in_(record_ids))
result = await db.execute(
select(
VideoUpscaleTask.chat_generation_task_id,
VideoUpscaleTask.generation_record_id,
VideoUpscaleTask.source_local_path,
).where(
or_(*owner_conditions),
VideoUpscaleTask.status == VideoUpscaleTaskStatus.FAILED.value,
VideoUpscaleTask.source_local_path.isnot(None),
VideoUpscaleTask.source_deleted_at.is_(None),
)
)
recoverable = []
for chat_task_id, generation_record_id, source_local_path in result.all():
if source_local_path and os.path.isfile(str(source_local_path)):
recoverable.append(
{
"owner_type": "chat_generation_task" if chat_task_id else "generation_record",
"owner_id": str(chat_task_id or generation_record_id),
}
)
if recoverable:
raise HTTPException(
status_code=409,
detail={
"message": "当前生成任务异常暂不能删除",
"items": recoverable,
"task_count": len(recoverable),
},
)
@@ -0,0 +1,128 @@
from __future__ import annotations
import asyncio
import os
import subprocess
from pathlib import Path
from app.config import settings
from app.services.video_cover_service import get_ffmpeg_bin
from app.services.video_upscale.media_service import build_part_mp4_path, is_valid_file, probe_video, safe_remove
class LocalVideoUpscaleError(RuntimeError):
pass
def _build_filter(target_width: int, target_height: int) -> str:
if target_width <= 0 or target_height <= 0 or target_width % 2 or target_height % 2:
raise LocalVideoUpscaleError("目标宽高必须是正偶数")
return (
"hqdn3d=0.8:0.6:2.5:1.8,"
f"scale={target_width}:{target_height}:"
"force_original_aspect_ratio=increase:force_divisible_by=2:flags=lanczos,"
f"crop={target_width}:{target_height}:(iw-ow)/2:(ih-oh)/2,"
"unsharp=5:5:0.40:5:5:0.0,"
"eq=contrast=1.02:saturation=1.03,"
"setsar=1"
)
def _run_ffmpeg_sync(
*,
source_path: str,
part_path: str,
target_width: int,
target_height: int,
timeout_seconds: int,
) -> None:
ffmpeg_bin = get_ffmpeg_bin() # 明确复用 config.py 的 FFMPEG_BIN。
cmd = [
ffmpeg_bin,
"-hide_banner",
"-nostdin",
"-y",
"-i",
source_path,
"-map",
"0:v:0",
"-map",
"0:a?",
"-vf",
_build_filter(target_width, target_height),
"-c:v",
"libx264",
"-preset",
"slow",
"-crf",
"18",
"-profile:v",
"high",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-c:a",
"aac",
"-b:a",
"192k",
part_path,
]
try:
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(30, int(timeout_seconds)),
shell=False,
)
except subprocess.TimeoutExpired as exc:
raise LocalVideoUpscaleError(f"本地 FFmpeg 超分超时: {timeout_seconds}") from exc
except OSError as exc:
raise LocalVideoUpscaleError(f"本地 FFmpeg 无法启动: {exc}") from exc
if result.returncode != 0:
raise LocalVideoUpscaleError(f"本地 FFmpeg 超分失败: {(result.stderr or '').strip()[-4000:]}")
async def execute_local_ffmpeg_crop(
*,
source_path: str,
final_path: str,
target_width: int,
target_height: int,
timeout_seconds: int | None = None,
) -> str:
if not is_valid_file(source_path):
raise LocalVideoUpscaleError(f"超分源视频不存在或为空: {source_path}")
if is_valid_file(final_path):
info = await probe_video(final_path)
if info.width == target_width and info.height == target_height:
return final_path
os.makedirs(os.path.dirname(final_path), exist_ok=True)
part_path = build_part_mp4_path(final_path)
safe_remove(part_path)
try:
await asyncio.to_thread(
_run_ffmpeg_sync,
source_path=source_path,
part_path=part_path,
target_width=target_width,
target_height=target_height,
timeout_seconds=int(timeout_seconds or settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS),
)
if not is_valid_file(part_path):
raise LocalVideoUpscaleError("本地 FFmpeg 输出文件为空")
info = await probe_video(part_path)
if info.width != target_width or info.height != target_height:
raise LocalVideoUpscaleError(
f"本地 FFmpeg 输出尺寸不正确: {info.width}x{info.height},预期 {target_width}x{target_height}"
)
os.replace(part_path, final_path)
return final_path
except Exception:
safe_remove(part_path)
raise
@@ -0,0 +1,108 @@
from __future__ import annotations
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from app.models.chat_generation_task import ChatGenerationTask
from app.models.generation_record import GenerationRecord
from app.services.operation_log_service import log_operation_event
def _sanitize_url(value: Any) -> Any:
if not isinstance(value, str) or not value.startswith(("http://", "https://")):
return value
parts = urlsplit(value)
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
def _sanitize_detail(value: Any) -> Any:
if isinstance(value, dict):
result: dict[str, Any] = {}
for key, item in value.items():
key_text = str(key).lower()
if key_text in {"authorization", "api_key", "volc_api_key"}:
result[key] = "***"
elif "url" in key_text:
result[key] = _sanitize_url(item)
else:
result[key] = _sanitize_detail(item)
return result
if isinstance(value, list):
return [_sanitize_detail(item) for item in value]
return value
def _owner_context(task: Any | None, upscale_task: Any | None) -> dict[str, Any]:
if isinstance(task, ChatGenerationTask):
return {
"owner_type": "chat_generation_task",
"owner_id": task.id,
"chat_generation_task_id": task.id,
"generation_record_id": None,
"project_id": None,
"generation_mode": task.generation_mode,
}
if isinstance(task, GenerationRecord):
return {
"owner_type": "generation_record",
"owner_id": task.id,
"chat_generation_task_id": None,
"generation_record_id": task.id,
"project_id": task.project_id,
"generation_mode": None,
}
chat_task_id = getattr(upscale_task, "chat_generation_task_id", None) if upscale_task else None
generation_record_id = getattr(upscale_task, "generation_record_id", None) if upscale_task else None
return {
"owner_type": "chat_generation_task" if chat_task_id else "generation_record" if generation_record_id else None,
"owner_id": chat_task_id or generation_record_id,
"chat_generation_task_id": chat_task_id,
"generation_record_id": generation_record_id,
"project_id": None,
"generation_mode": None,
}
def log_video_upscale_event(
*,
event_type: str,
event_status: str = "success",
task: Any | None = None,
upscale_task: Any | None = None,
remote_request_id: str | None = None,
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
"""统一复用 operation_log_service 写入视频超分步骤日志。"""
final_detail = _owner_context(task, upscale_task)
final_detail.update(_sanitize_detail(dict(detail or {})))
if upscale_task is not None:
final_detail.update(
{
"upscale_task_id": getattr(upscale_task, "id", None),
"processor_key": getattr(upscale_task, "processor_key", None),
"upscale_status": getattr(upscale_task, "status", None),
"upscale_stage": getattr(upscale_task, "stage", None),
"attempt_count": getattr(upscale_task, "attempt_count", None),
"failure_count": getattr(upscale_task, "failure_count", None),
"provider_task_id": getattr(upscale_task, "provider_task_id", None),
"input_source_type": getattr(upscale_task, "input_source_type", None),
"target_width": getattr(upscale_task, "target_width", None),
"target_height": getattr(upscale_task, "target_height", None),
}
)
owner_id = final_detail.get("owner_id")
log_operation_event(
domain="video_upscale",
event_type=event_type,
event_status=event_status,
source="service",
user_id=getattr(task, "user_id", None) if task is not None else None,
group_id=getattr(task, "parent_task_id", None) if task is not None else None,
task_id=owner_id,
remote_request_id=remote_request_id,
message=message,
detail=final_detail,
error=error,
)
@@ -0,0 +1,207 @@
from __future__ import annotations
import asyncio
import json
import os
import shutil
import subprocess
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlsplit
import httpx
from app.config import settings
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.video_cover_service import get_ffmpeg_bin
@dataclass(slots=True)
class VideoMediaInfo:
width: int
height: int
duration_seconds: float
fps: float
codec_name: str | None = None
color_transfer: str | None = None
color_primaries: str | None = None
color_space: str | None = None
def build_part_mp4_path(final_path: str) -> str:
path = Path(final_path)
return str(path.with_name(f"{path.stem}.{uuid.uuid4().hex}.part.mp4"))
def safe_remove(path: str | None) -> bool:
if not path:
return True
try:
if os.path.exists(path):
os.remove(path)
return not os.path.exists(path)
except OSError:
return False
def is_valid_file(path: str | None) -> bool:
if not path:
return False
try:
return os.path.isfile(path) and os.path.getsize(path) > 0
except OSError:
return False
def get_ffprobe_bin() -> str:
ffmpeg = Path(get_ffmpeg_bin())
sibling = ffmpeg.with_name("ffprobe.exe" if ffmpeg.suffix.lower() == ".exe" else "ffprobe")
if sibling.exists():
return str(sibling)
found = shutil.which("ffprobe") or shutil.which("ffprobe.exe")
if found:
return found
raise RuntimeError("未找到 ffprobe,请确保其与 FFMPEG_BIN 同目录或已加入 PATH")
def _parse_fps(value: str | None) -> float:
if not value:
return 0.0
try:
if "/" in value:
left, right = value.split("/", 1)
denominator = float(right)
return float(left) / denominator if denominator else 0.0
return float(value)
except Exception:
return 0.0
def probe_video_sync(path: str, timeout_seconds: int = 30) -> VideoMediaInfo:
if not is_valid_file(path):
raise RuntimeError(f"视频文件不存在或为空: {path}")
cmd = [
get_ffprobe_bin(), "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,codec_name,avg_frame_rate,color_transfer,color_primaries,color_space:format=duration",
"-of", "json", path,
]
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(5, int(timeout_seconds)),
shell=False,
)
if result.returncode != 0:
raise RuntimeError(f"ffprobe 校验失败: {(result.stderr or '').strip()[-2000:]}")
try:
payload = json.loads(result.stdout or "{}")
stream = (payload.get("streams") or [])[0]
width = int(stream.get("width") or 0)
height = int(stream.get("height") or 0)
duration = float((payload.get("format") or {}).get("duration") or 0)
except Exception as exc:
raise RuntimeError(f"ffprobe 响应解析失败: {exc}") from exc
if width <= 0 or height <= 0:
raise RuntimeError("ffprobe 未读取到有效视频宽高")
return VideoMediaInfo(
width=width,
height=height,
duration_seconds=duration,
fps=_parse_fps(stream.get("avg_frame_rate")),
codec_name=stream.get("codec_name"),
color_transfer=stream.get("color_transfer"),
color_primaries=stream.get("color_primaries"),
color_space=stream.get("color_space"),
)
async def probe_video(path: str, timeout_seconds: int = 30) -> VideoMediaInfo:
return await asyncio.to_thread(probe_video_sync, path, timeout_seconds)
def parse_tos_signed_url_expiry(url: str | None) -> tuple[datetime | None, datetime | None]:
if not url:
return None, None
params = {key.lower(): value for key, value in parse_qsl(urlsplit(url).query, keep_blank_values=True)}
date_text = params.get("x-tos-date")
expires_text = params.get("x-tos-expires")
if not date_text or not expires_text:
return None, None
try:
signed_at = datetime.strptime(date_text, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
expires_seconds = int(expires_text)
if expires_seconds <= 0:
return None, None
return signed_at, signed_at + timedelta(seconds=expires_seconds)
except Exception:
return None, None
async def probe_remote_url(url: str) -> bool:
timeout = httpx.Timeout(
connect=max(1, int(settings.VIDEO_UPSCALE_REMOTE_URL_PROBE_CONNECT_TIMEOUT_SECONDS or 3)),
read=max(1, int(settings.VIDEO_UPSCALE_REMOTE_URL_PROBE_READ_TIMEOUT_SECONDS or 5)),
write=5,
pool=5,
)
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
async with client.stream("GET", url, headers={"Range": "bytes=0-0"}) as response:
if response.status_code not in {200, 206}:
return False
async for chunk in response.aiter_bytes():
return bool(chunk)
return False
except Exception:
return False
def build_source_resource_url(source_local_path: str) -> str:
root = Path(settings.STORAGE_LOCAL_PATH).resolve()
path = Path(source_local_path).resolve()
try:
relative = path.relative_to(root).as_posix()
except ValueError as exc:
raise RuntimeError("超分源视频不在 STORAGE_LOCAL_PATH 下,无法生成签名 URL") from exc
return f"{settings.BASE_URL.rstrip('/')}/generate/videos/{relative}"
def build_local_source_signed_url(source_local_path: str, expire_seconds: int) -> str:
return build_resource_signed_url(
build_source_resource_url(source_local_path),
expire_seconds=max(600, int(expire_seconds)),
)
async def download_video_to_path(url: str, final_path: str, timeout_seconds: int) -> str:
if is_valid_file(final_path):
try:
await probe_video(final_path)
return final_path
except Exception:
safe_remove(final_path)
os.makedirs(os.path.dirname(final_path), exist_ok=True)
part_path = build_part_mp4_path(final_path)
timeout = httpx.Timeout(max(30, int(timeout_seconds)))
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
async with client.stream("GET", url) as response:
response.raise_for_status()
with open(part_path, "wb") as file_obj:
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
file_obj.write(chunk)
if not is_valid_file(part_path):
raise RuntimeError("视频下载完成但临时文件为空")
await probe_video(part_path)
os.replace(part_path, final_path)
return final_path
except Exception:
safe_remove(part_path)
raise
@@ -0,0 +1,120 @@
from __future__ import annotations
from typing import Any, TypeAlias
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.generation_status import GenerationRecordPipelineStage, GenerationStatus
from app.enums.generation_task import ChatGenerationPipelineStage, ChatGenerationTaskStatus
from app.models.chat_generation_task import ChatGenerationTask
from app.models.generation_record import GenerationRecord
from app.models.video_upscale_task import VideoUpscaleTask
from app.services.generation.ai.task_group_service import aggregate_parent_for_child
from app.services.generation.module_hook_service import notify_chat_generation_task_finished
VideoUpscaleOwner: TypeAlias = ChatGenerationTask | GenerationRecord
def owner_type(owner: VideoUpscaleOwner | None) -> str | None:
if isinstance(owner, ChatGenerationTask):
return "chat_generation_task"
if isinstance(owner, GenerationRecord):
return "generation_record"
return None
def owner_id(owner: VideoUpscaleOwner | None) -> str | None:
return str(getattr(owner, "id", "") or "") or None
def owner_is_generating(owner: VideoUpscaleOwner) -> bool:
if isinstance(owner, ChatGenerationTask):
return owner.status == ChatGenerationTaskStatus.GENERATING.value
return owner.status == GenerationStatus.generating.value
def owner_is_completed(owner: VideoUpscaleOwner) -> bool:
if isinstance(owner, ChatGenerationTask):
return owner.status == ChatGenerationTaskStatus.COMPLETED.value
return owner.status == GenerationStatus.completed.value
def set_owner_stage(owner: VideoUpscaleOwner, stage: str) -> None:
owner.pipeline_stage = stage
def upscale_stage_value(owner: VideoUpscaleOwner, chat_stage: ChatGenerationPipelineStage | str) -> str:
value = chat_stage.value if hasattr(chat_stage, "value") else str(chat_stage)
if isinstance(owner, ChatGenerationTask):
return value
try:
return GenerationRecordPipelineStage(value).value
except ValueError:
return value
async def load_upscale_owner(
db: AsyncSession,
upscale: VideoUpscaleTask,
*,
for_update: bool,
) -> VideoUpscaleOwner | None:
if upscale.chat_generation_task_id:
query = select(ChatGenerationTask).where(
ChatGenerationTask.id == upscale.chat_generation_task_id,
ChatGenerationTask.deleted_at.is_(None),
)
elif upscale.generation_record_id:
query = select(GenerationRecord).where(
GenerationRecord.id == upscale.generation_record_id,
GenerationRecord.deleted_at.is_(None),
)
else:
return None
if for_update:
query = query.with_for_update()
result = await db.execute(query.limit(1))
return result.scalar_one_or_none()
async def mark_owner_upscale_failed(
db: AsyncSession,
owner: VideoUpscaleOwner,
*,
error_message: str,
) -> None:
if isinstance(owner, ChatGenerationTask):
owner.status = ChatGenerationTaskStatus.FAILED.value
owner.pipeline_stage = ChatGenerationPipelineStage.UPSCALE_FAILED.value
owner.error_message = error_message
await notify_chat_generation_task_finished(db, owner)
await aggregate_parent_for_child(db, owner)
return
owner.status = GenerationStatus.failed.value
owner.pipeline_stage = GenerationRecordPipelineStage.UPSCALE_FAILED.value
owner.error_message = error_message
def restore_owner_for_upscale_retry(owner: VideoUpscaleOwner) -> None:
if isinstance(owner, ChatGenerationTask):
owner.status = ChatGenerationTaskStatus.GENERATING.value
owner.pipeline_stage = ChatGenerationPipelineStage.UPSCALE_QUEUED.value
else:
owner.status = GenerationStatus.generating.value
owner.pipeline_stage = GenerationRecordPipelineStage.UPSCALE_QUEUED.value
owner.error_message = None
def owner_context(owner: VideoUpscaleOwner | None) -> dict[str, Any]:
if owner is None:
return {}
return {
"owner_type": owner_type(owner),
"owner_id": owner_id(owner),
"chat_generation_task_id": owner.id if isinstance(owner, ChatGenerationTask) else None,
"generation_record_id": owner.id if isinstance(owner, GenerationRecord) else None,
"project_id": getattr(owner, "project_id", None),
"generation_mode": getattr(owner, "generation_mode", None),
}
@@ -0,0 +1,176 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any, Iterable
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.video_upscale import (
ALL_PROCESSOR_KEYS,
VIDEO_UPSCALE_RESOLUTIONS,
VideoUpscaleProcessorKey,
normalize_video_upscale_resolution,
video_upscale_short_edge_pixels,
)
from app.services.video_upscale.config_service import get_runtime_video_upscale_config
from app.services.video_upscale.log_service import log_video_upscale_event
def normalize_resolution(value: str | None) -> str:
return normalize_video_upscale_resolution(value)
def _even(value: float) -> int:
rounded = int(round(value))
if rounded < 2:
rounded = 2
return rounded if rounded % 2 == 0 else rounded + 1
def parse_aspect_ratio(value: str | None) -> tuple[int, int]:
text = str(value or "").strip()
try:
left, right = text.split(":", 1)
width = int(left)
height = int(right)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"视频比例格式不合法: {text}") from exc
if width <= 0 or height <= 0:
raise HTTPException(status_code=400, detail=f"视频比例格式不合法: {text}")
return width, height
def calculate_target_dimensions(*, aspect_ratio: str, target_resolution: str) -> tuple[int, int]:
ratio_w, ratio_h = parse_aspect_ratio(aspect_ratio)
try:
pixels = video_upscale_short_edge_pixels(target_resolution)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if ratio_w >= ratio_h:
height = _even(pixels)
width = _even(height * ratio_w / ratio_h)
else:
width = _even(pixels)
height = _even(width * ratio_h / ratio_w)
return width, height
def _runtime_processor_snapshot(processor_key: str) -> dict[str, Any]:
if processor_key not in ALL_PROCESSOR_KEYS:
raise HTTPException(status_code=500, detail=f"未注册的超分处理器: {processor_key}")
is_local = processor_key == VideoUpscaleProcessorKey.LOCAL_FFMPEG_CROP_V1.value
return {
"max_attempts": max(1, int(settings.VIDEO_UPSCALE_MAX_ATTEMPTS or 3)),
"timeout_seconds": int(
settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS
if is_local
else settings.VIDEO_UPSCALE_REMOTE_POLL_TIMEOUT_SECONDS
),
"request_timeout_seconds": max(3, int(settings.VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS or 30)),
"poll_timeout_seconds": max(60, int(settings.VIDEO_UPSCALE_REMOTE_POLL_TIMEOUT_SECONDS or 7200)),
"poll_interval_seconds": max(5, int(settings.VIDEO_UPSCALE_REMOTE_POLL_INTERVAL_SECONDS or 30)),
"source_url_expire_seconds": max(600, int(settings.VIDEO_UPSCALE_LOCAL_SIGNED_URL_EXPIRE_SECONDS or 7200)),
"bitrate_level": "medium",
"scene": "aigc",
"fps": None,
"queue_id": None,
}
async def build_video_upscale_snapshot(
db: AsyncSession,
*,
target_resolution: str,
aspect_ratio: str,
supported_provider_resolutions: Iterable[str] | None = None,
) -> tuple[str, bool, str | None]:
config = await get_runtime_video_upscale_config(db)
original_resolution = normalize_resolution(target_resolution)
if original_resolution not in VIDEO_UPSCALE_RESOLUTIONS:
return str(target_resolution).strip(), False, None
if not config.get("enabled"):
log_video_upscale_event(
event_type="upscale_snapshot_bypassed",
event_status="bypassed",
detail={"reason": "global_disabled", "target_resolution": original_resolution, "aspect_ratio": aspect_ratio},
)
return original_resolution, False, None
matched: dict[str, Any] | None = None
for rule in config.get("rules") or []:
if not rule.get("enabled"):
continue
if normalize_resolution(rule.get("target_resolution")) == original_resolution:
matched = dict(rule)
break
if matched is None:
log_video_upscale_event(
event_type="upscale_snapshot_bypassed",
event_status="bypassed",
detail={"reason": "rule_not_matched", "target_resolution": original_resolution, "aspect_ratio": aspect_ratio},
)
return original_resolution, False, None
provider_resolution = normalize_resolution(matched.get("provider_generation_resolution"))
processor_key = str(matched.get("processor_key") or "").strip()
processor = _runtime_processor_snapshot(processor_key)
supported = {normalize_resolution(item) for item in (supported_provider_resolutions or []) if item}
if supported and provider_resolution not in supported:
raise HTTPException(
status_code=400,
detail=f"超分规则要求实际生成 {provider_resolution},但当前视频引擎不支持该分辨率",
)
target_width, target_height = calculate_target_dimensions(
aspect_ratio=aspect_ratio,
target_resolution=original_resolution,
)
target_short_edge_pixels = video_upscale_short_edge_pixels(original_resolution)
snapshot: dict[str, Any] = {
"config_version": int(config.get("version") or 1),
"target_resolution": original_resolution,
"provider_generation_resolution": provider_resolution,
"processor_key": processor_key,
"processor": processor,
"aspect_ratio": aspect_ratio,
"target_short_edge_pixels": target_short_edge_pixels,
"target_width": target_width,
"target_height": target_height,
"delete_source_after_success": bool(config.get("delete_source_after_success", True)),
"snapshot_created_at": datetime.now(timezone.utc).isoformat(),
}
canonical = json.dumps(snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
snapshot["snapshot_hash"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
log_video_upscale_event(
event_type="upscale_snapshot_matched",
detail={
"target_resolution": original_resolution,
"provider_generation_resolution": provider_resolution,
"processor_key": processor_key,
"aspect_ratio": aspect_ratio,
"target_width": target_width,
"target_height": target_height,
"delete_source_after_success": snapshot["delete_source_after_success"],
"config_version": snapshot["config_version"],
"snapshot_hash": snapshot["snapshot_hash"],
},
)
return provider_resolution, True, json.dumps(snapshot, ensure_ascii=False, separators=(",", ":"))
def parse_video_upscale_snapshot(value: str | None) -> dict[str, Any]:
if not value:
return {}
try:
data = json.loads(value)
except Exception as exc:
raise RuntimeError(f"超分快照不是合法 JSON: {exc}") from exc
if not isinstance(data, dict):
raise RuntimeError("超分快照必须是 JSON 对象")
return data
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,270 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from app.config import settings
from app.enums.video_upscale import VideoUpscaleProcessorKey
class VolcMediaKitError(RuntimeError):
def __init__(
self,
message: str,
*,
code: str | None = None,
error_type: str | None = None,
param: str | None = None,
retryable: bool = True,
http_status: int | None = None,
request_id: str | None = None,
endpoint: str | None = None,
response_payload: dict[str, Any] | None = None,
):
super().__init__(message)
self.code = code
self.error_type = error_type
self.param = param
self.retryable = retryable
self.http_status = http_status
self.request_id = request_id
self.endpoint = endpoint
self.response_payload = response_payload or {}
def log_detail(self) -> dict[str, Any]:
return {
"endpoint": self.endpoint,
"http_status": self.http_status,
"request_id": self.request_id,
"error_code": self.code,
"error_type": self.error_type,
"error_param": self.param,
"error_message": str(self),
"retryable": self.retryable,
}
@dataclass(slots=True)
class VolcSubmitResult:
task_id: str
request_id: str | None
request_payload: dict[str, Any]
response_payload: dict[str, Any]
endpoint: str
@dataclass(slots=True)
class VolcQueryResult:
status: str
request_id: str | None
result: dict[str, Any] | None
error: dict[str, Any] | None
expires_at: int | None
response_payload: dict[str, Any]
endpoint: str
def _headers() -> dict[str, str]:
api_key = str(settings.VOLC_API_KEY or "").strip()
if not api_key:
raise VolcMediaKitError("VOLC_API_KEY 未配置", code="MissingApiKey", retryable=False)
return {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
def _base_url() -> str:
return str(settings.VOLC_MEDIAKIT_API_BASE or "https://mediakit.cn-beijing.volces.com").rstrip("/")
def _error_from_payload(
payload: dict[str, Any],
default_message: str,
*,
http_status: int | None = None,
endpoint: str | None = None,
) -> VolcMediaKitError:
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = str(error.get("code") or "") or None
error_type = str(error.get("type") or "") or None
param = str(error.get("param") or "") or None
message = str(error.get("message") or default_message)
retryable = True
if http_status in {400, 401, 403, 404, 422}:
retryable = False
if code in {"InvalidParameter", "Unauthorized", "Forbidden", "NotFound"} or error_type in {"BadRequest", "AuthError"}:
retryable = False
request_id = str(payload.get("request_id") or "") or None
return VolcMediaKitError(
message,
code=code,
error_type=error_type,
param=param,
retryable=retryable,
http_status=http_status,
request_id=request_id,
endpoint=endpoint,
response_payload=payload,
)
def build_submit_payload(
*,
processor_key: str,
video_url: str,
target_resolution: str,
target_width: int,
target_height: int,
processor: dict[str, Any],
client_token: str,
) -> tuple[str, dict[str, Any]]:
payload: dict[str, Any] = {
"video_url": video_url,
"bitrate_level": processor.get("bitrate_level") or "medium",
"client_token": client_token[:64],
}
if processor_key == VideoUpscaleProcessorKey.VOLC_LARGE_MODEL_V1.value:
endpoint = "/api/v1/tools/enhance-video-generative"
normalized = str(target_resolution or "").strip().lower()
if normalized not in {"720p", "1080p", "2k"}:
raise VolcMediaKitError(
"画质增强大模型仅支持目标分辨率 720p、1080p、2K",
code="InvalidResolution",
retryable=False,
endpoint=endpoint,
)
payload["resolution"] = normalized
elif processor_key in {
VideoUpscaleProcessorKey.VOLC_STANDARD_V1.value,
VideoUpscaleProcessorKey.VOLC_PROFESSIONAL_V1.value,
}:
endpoint = "/api/v1/tools/enhance-video"
payload["tool_version"] = "standard" if processor_key == VideoUpscaleProcessorKey.VOLC_STANDARD_V1.value else "professional"
payload["resolution_limit"] = min(int(target_width), int(target_height))
if processor_key == VideoUpscaleProcessorKey.VOLC_STANDARD_V1.value:
payload["scene"] = processor.get("scene") or "aigc"
else:
raise VolcMediaKitError(
f"不支持的火山超分处理器: {processor_key}",
code="UnsupportedProcessor",
retryable=False,
)
return endpoint, payload
async def submit_video_enhance(
*,
processor_key: str,
video_url: str,
target_resolution: str,
target_width: int,
target_height: int,
processor: dict[str, Any],
client_token: str,
) -> VolcSubmitResult:
endpoint, payload = build_submit_payload(
processor_key=processor_key,
video_url=video_url,
target_resolution=target_resolution,
target_width=target_width,
target_height=target_height,
processor=processor,
client_token=client_token,
)
timeout = max(3, int(processor.get("request_timeout_seconds") or settings.VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS))
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
response = await client.post(f"{_base_url()}{endpoint}", headers=_headers(), json=payload)
try:
data = response.json()
except Exception:
data = {"success": False, "error": {"message": response.text[:2000]}}
if response.status_code >= 400:
raise _error_from_payload(
data,
f"火山超分提交失败 HTTP {response.status_code}",
http_status=response.status_code,
endpoint=endpoint,
)
except VolcMediaKitError:
raise
except (httpx.TimeoutException, httpx.NetworkError) as exc:
raise VolcMediaKitError(
f"火山超分提交网络异常: {exc}",
code="NetworkError",
retryable=True,
endpoint=endpoint,
) from exc
if not bool(data.get("success")) or not data.get("task_id"):
raise _error_from_payload(data, "火山超分提交失败", endpoint=endpoint)
return VolcSubmitResult(
task_id=str(data["task_id"]),
request_id=str(data.get("request_id")) if data.get("request_id") else None,
request_payload=payload,
response_payload=data,
endpoint=endpoint,
)
async def query_task(task_id: str, *, request_timeout_seconds: int | None = None) -> VolcQueryResult:
endpoint = f"/api/v1/tasks/{task_id}"
timeout = max(3, int(request_timeout_seconds or settings.VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS))
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
response = await client.get(f"{_base_url()}{endpoint}", headers=_headers())
try:
data = response.json()
except Exception:
data = {"success": False, "error": {"message": response.text[:2000]}}
if response.status_code >= 400:
raise _error_from_payload(
data,
f"火山超分任务查询失败 HTTP {response.status_code}",
http_status=response.status_code,
endpoint=endpoint,
)
except VolcMediaKitError:
raise
except (httpx.TimeoutException, httpx.NetworkError) as exc:
raise VolcMediaKitError(
f"火山超分查询网络异常: {exc}",
code="NetworkError",
retryable=True,
endpoint=endpoint,
) from exc
if not bool(data.get("success")):
raise _error_from_payload(data, "火山超分任务查询失败", endpoint=endpoint)
status = str(data.get("status") or "").strip().lower()
if status not in {"running", "completed", "failed"}:
raise VolcMediaKitError(
f"火山超分返回未知任务状态: {status}",
code="UnknownStatus",
retryable=True,
request_id=str(data.get("request_id") or "") or None,
endpoint=endpoint,
response_payload=data,
)
expires_raw = data.get("expires_at")
try:
expires_at = int(expires_raw) if expires_raw is not None else None
except Exception:
expires_at = None
return VolcQueryResult(
status=status,
request_id=str(data.get("request_id")) if data.get("request_id") else None,
result=data.get("result") if isinstance(data.get("result"), dict) else None,
error=data.get("error") if isinstance(data.get("error"), dict) else None,
expires_at=expires_at,
response_payload=data,
endpoint=endpoint,
)
def is_remote_input_access_error(error: dict[str, Any] | None) -> bool:
if not error:
return False
text = " ".join(str(error.get(key) or "") for key in ("code", "message", "param", "type")).lower()
fragments = ("403", "forbidden", "url expired", "downloadfailed", "urldownloadfail", "download file", "url无法", "下载失败")
return any(fragment in text for fragment in fragments)
+40 -1
View File
@@ -18,6 +18,7 @@ CELERY_TASK_IMPORTS = (
"app.tasks.generation_poll_tasks", "app.tasks.generation_poll_tasks",
"app.tasks.generation_download_tasks", "app.tasks.generation_download_tasks",
"app.tasks.generation_recovery_tasks", "app.tasks.generation_recovery_tasks",
"app.tasks.video_upscale_tasks",
"app.tasks.hot_opening_replicate_tasks", "app.tasks.hot_opening_replicate_tasks",
"app.tasks.shot_replicate_tasks", "app.tasks.shot_replicate_tasks",
"app.tasks.shot_replicate_flow_tasks", "app.tasks.shot_replicate_flow_tasks",
@@ -52,6 +53,14 @@ def _beat_schedule() -> dict:
"priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER, "priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
}, },
} }
schedule["video-upscale-recovery-every-minute"] = {
"task": CeleryTaskName.VIDEO_UPSCALE_RECOVER.value,
"schedule": 60,
"options": {
"queue": RECOVERY_QUEUE,
"priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
},
}
schedule["private-portrait-sync-due-assets-every-minute"] = { schedule["private-portrait-sync-due-assets-every-minute"] = {
"task": CeleryTaskName.PRIVATE_PORTRAIT_SYNC_DUE_ASSETS.value, "task": CeleryTaskName.PRIVATE_PORTRAIT_SYNC_DUE_ASSETS.value,
"schedule": 60, "schedule": 60,
@@ -100,10 +109,24 @@ if broker_url:
"shot_replicate.split_one_segment": {"ignore_result": True}, "shot_replicate.split_one_segment": {"ignore_result": True},
"shot_replicate.start_image_prompt_optimize": {"ignore_result": True}, "shot_replicate.start_image_prompt_optimize": {"ignore_result": True},
"shot_replicate.start_video_prompt_optimize": {"ignore_result": True}, "shot_replicate.start_video_prompt_optimize": {"ignore_result": True},
CeleryTaskName.VIDEO_UPSCALE_EXECUTE_LOCAL.value: {
"ignore_result": True,
"soft_time_limit": max(60, int(settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS or 3600)) + 60,
"time_limit": max(60, int(settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS or 3600)) + 300,
},
CeleryTaskName.VIDEO_UPSCALE_SUBMIT_REMOTE.value: {"ignore_result": True},
CeleryTaskName.VIDEO_UPSCALE_POLL_REMOTE.value: {"ignore_result": True},
CeleryTaskName.VIDEO_UPSCALE_DOWNLOAD_REMOTE_RESULT.value: {"ignore_result": True},
CeleryTaskName.VIDEO_UPSCALE_FINALIZE.value: {"ignore_result": True},
CeleryTaskName.VIDEO_UPSCALE_RECOVER.value: {"ignore_result": True},
}, },
worker_prefetch_multiplier=1, worker_prefetch_multiplier=1,
broker_transport_options={ broker_transport_options={
"visibility_timeout": 3600, "visibility_timeout": max(
3600,
int(settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS or 3600) + 600,
int(settings.VIDEO_UPSCALE_REMOTE_RESULT_DOWNLOAD_TIMEOUT_SECONDS or 600) + 600,
),
"queue_order_strategy": "priority", "queue_order_strategy": "priority",
"priority_steps": list(range(10)), "priority_steps": list(range(10)),
"sep": ":", "sep": ":",
@@ -112,6 +135,22 @@ if broker_url:
CeleryTaskName.CHATAPI_CREATE.value: {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value}, CeleryTaskName.CHATAPI_CREATE.value: {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
CeleryTaskName.POLL_GENERATION.value: {"queue": CeleryQueue.GEN_PROVIDER_POLL.value}, CeleryTaskName.POLL_GENERATION.value: {"queue": CeleryQueue.GEN_PROVIDER_POLL.value},
CeleryTaskName.DOWNLOAD_GENERATION_RESULT.value: {"queue": CeleryQueue.GEN_RESULT_DOWNLOAD.value}, CeleryTaskName.DOWNLOAD_GENERATION_RESULT.value: {"queue": CeleryQueue.GEN_RESULT_DOWNLOAD.value},
CeleryTaskName.VIDEO_UPSCALE_EXECUTE_LOCAL.value: {
"queue": settings.VIDEO_UPSCALE_LOCAL_QUEUE or CeleryQueue.GEN_VIDEO_UPSCALE_LOCAL.value
},
CeleryTaskName.VIDEO_UPSCALE_SUBMIT_REMOTE.value: {
"queue": settings.VIDEO_UPSCALE_REMOTE_QUEUE or CeleryQueue.GEN_VIDEO_UPSCALE_REMOTE.value
},
CeleryTaskName.VIDEO_UPSCALE_POLL_REMOTE.value: {
"queue": settings.VIDEO_UPSCALE_REMOTE_QUEUE or CeleryQueue.GEN_VIDEO_UPSCALE_REMOTE.value
},
CeleryTaskName.VIDEO_UPSCALE_DOWNLOAD_REMOTE_RESULT.value: {
"queue": settings.VIDEO_UPSCALE_REMOTE_QUEUE or CeleryQueue.GEN_VIDEO_UPSCALE_REMOTE.value
},
CeleryTaskName.VIDEO_UPSCALE_FINALIZE.value: {
"queue": settings.VIDEO_UPSCALE_LOCAL_QUEUE or CeleryQueue.GEN_VIDEO_UPSCALE_LOCAL.value
},
CeleryTaskName.VIDEO_UPSCALE_RECOVER.value: {"queue": RECOVERY_QUEUE},
CeleryTaskName.DISPATCH_DUE_POLL.value: {"queue": RECOVERY_QUEUE}, CeleryTaskName.DISPATCH_DUE_POLL.value: {"queue": RECOVERY_QUEUE},
"hot_opening.start_image_prompt_optimize": {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value}, "hot_opening.start_image_prompt_optimize": {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
"hot_opening.start_video_prompt_optimize": {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value}, "hot_opening.start_video_prompt_optimize": {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
@@ -18,10 +18,10 @@ from app.enums.generation_task import (
from app.models.base import async_session from app.models.base import async_session
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
from app.services.error_codes import extract_error_message from app.services.error_codes import extract_error_message
from app.services.generation_log_service import log_task_event from app.services.generation.log_service import log_task_event
from app.services.generation_poll_schedule_service import ensure_video_poll_fields from app.services.generation.poll_schedule_service import ensure_video_poll_fields
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation.refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.generation_provider_service import create_provider_task from app.services.generation.provider_service import create_provider_task
from app.services.media_token_usage_snapshot_service import sync_chat_generation_task_media_token_snapshot from app.services.media_token_usage_snapshot_service import sync_chat_generation_task_media_token_snapshot
from app.services.redis_registry_service import ensure_aware_utc from app.services.redis_registry_service import ensure_aware_utc
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
@@ -91,7 +91,7 @@ def _build_optimized_prompt_by_params(task: ChatGenerationTask) -> str:
duration = _get_first_value(task, "duration") duration = _get_first_value(task, "duration")
aspect_ratio = _get_first_value(task, "aspect_ratio") aspect_ratio = _get_first_value(task, "aspect_ratio")
resolution = _get_first_value(task, "resolution") resolution = _get_first_value(task, "provider_generation_resolution", "resolution")
image_size = _get_first_value(task, "image_size") image_size = _get_first_value(task, "image_size")
image_px = _get_first_value(task, "image_px") image_px = _get_first_value(task, "image_px")
image_proportion = _get_first_value(task, "image_proportion") image_proportion = _get_first_value(task, "image_proportion")
@@ -138,14 +138,22 @@ async def _run(task_id: str):
).with_for_update().limit(1)) ).with_for_update().limit(1))
task = result.scalar_one_or_none() task = result.scalar_one_or_none()
if not task or task.generation_mode not in ALLOWED_GENERATION_MODES: is_image_main = bool(
task
and task.generation_mode == GenerationMode.CHATAPI_MAIN.value
and task.gen_type == GenerationType.IMAGE.value
and int(task.generation_count or 1) > 1
)
if not task or (task.generation_mode not in ALLOWED_GENERATION_MODES and not is_image_main):
return return
if task.status != ChatGenerationTaskStatus.GENERATING.value: if task.status != ChatGenerationTaskStatus.GENERATING.value:
return return
deadline_at = ensure_aware_utc(task.deadline_at) deadline_at = ensure_aware_utc(task.deadline_at)
if deadline_at and datetime.now(timezone.utc) > deadline_at: # 图片 main 的 deadline 与 provider claim 由 image_batch_service 原子处理,
# 避免重复 Celery 消息在有效租约期间把正在执行的批次错误退款。
if not is_image_main and deadline_at and datetime.now(timezone.utc) > deadline_at:
await mark_chat_generation_task_failed_and_refund_once( await mark_chat_generation_task_failed_and_refund_once(
db, db,
task=task, task=task,
@@ -159,8 +167,10 @@ async def _run(task_id: str):
to_status=ChatGenerationTaskStatus.FAILED.value, to_status=ChatGenerationTaskStatus.FAILED.value,
to_stage=ChatGenerationPipelineStage.TIMEOUT.value, to_stage=ChatGenerationPipelineStage.TIMEOUT.value,
) )
from app.services.generation_module_hook_service import notify_chat_generation_task_finished from app.services.generation.module_hook_service import notify_chat_generation_task_finished
from app.services.generation.ai.task_group_service import aggregate_parent_for_child
await notify_chat_generation_task_finished(db, task) await notify_chat_generation_task_finished(db, task)
await aggregate_parent_for_child(db, task)
await db.commit() await db.commit()
return return
@@ -202,6 +212,12 @@ async def _run(task_id: str):
}, },
) )
if is_image_main:
from app.services.generation.ai.image_batch_service import run_image_main_batch
await run_image_main_batch(db, task)
return
if task.seedance_task_id or task.provider_task_id: if task.seedance_task_id or task.provider_task_id:
task.pipeline_stage = ChatGenerationPipelineStage.WAITING_REMOTE.value task.pipeline_stage = ChatGenerationPipelineStage.WAITING_REMOTE.value
if task.gen_type == GenerationType.VIDEO.value: if task.gen_type == GenerationType.VIDEO.value:
@@ -302,16 +318,41 @@ async def _run(task_id: str):
if task: if task:
error_message = extract_error_message(exc, "生成任务") if callable(extract_error_message) else str(exc) error_message = extract_error_message(exc, "生成任务") if callable(extract_error_message) else str(exc)
await mark_chat_generation_task_failed_and_refund_once( if is_image_main:
db, # image_batch_service 负责供应商/拆分失败退款。若 child 已落库,
task=task, # 顶层兜底绝不能再把 main 退款。
error_message=error_message, child_result = await db.execute(
pipeline_stage=ChatGenerationPipelineStage.FAILED.value, select(ChatGenerationTask.id).where(
) ChatGenerationTask.parent_task_id == task.id,
ChatGenerationTask.generation_mode == GenerationMode.CHATAPI_CHILD.value,
).limit(1)
)
has_children = child_result.scalar_one_or_none() is not None
if not has_children:
task.provider_create_claim_token = None
task.provider_create_lease_until = None
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message=error_message,
pipeline_stage=ChatGenerationPipelineStage.FAILED.value,
)
else:
from app.services.generation.ai.task_group_service import aggregate_main_task_status
await aggregate_main_task_status(db, parent_task_id=str(task.id))
else:
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message=error_message,
pipeline_stage=ChatGenerationPipelineStage.FAILED.value,
)
await db.commit() await db.commit()
await log_task_event(task, event_type=ChatGenerationTaskEventType.TASK_FAILED.value, message=task.error_message) await log_task_event(task, event_type=ChatGenerationTaskEventType.TASK_FAILED.value, message=error_message)
from app.services.generation_module_hook_service import notify_chat_generation_task_finished from app.services.generation.module_hook_service import notify_chat_generation_task_finished
from app.services.generation.ai.task_group_service import aggregate_parent_for_child
await notify_chat_generation_task_finished(db, task) await notify_chat_generation_task_finished(db, task)
await aggregate_parent_for_child(db, task)
await db.commit() await db.commit()
@@ -17,6 +17,7 @@ from app.enums.generation_task import (
ChatGenerationPipelineStage, ChatGenerationPipelineStage,
ChatGenerationTaskEventType, ChatGenerationTaskEventType,
ChatGenerationTaskStatus, ChatGenerationTaskStatus,
GenerationMode,
GenerationType, GenerationType,
) )
from app.models.base import async_session from app.models.base import async_session
@@ -28,9 +29,9 @@ from app.services.celery_download_recovery_service import (
upsert_download_active, upsert_download_active,
) )
from app.services.error_codes import extract_error_message from app.services.error_codes import extract_error_message
from app.services.generation_download_service import download_generation_result from app.services.generation.download_service import download_generation_result, download_video_upscale_source
from app.services.generation_log_service import log_task_event from app.services.generation.log_service import log_task_event
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation.refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.media_token_usage_snapshot_service import sync_chat_generation_task_media_token_snapshot from app.services.media_token_usage_snapshot_service import sync_chat_generation_task_media_token_snapshot
from app.services.resource_accounting_service import record_chat_task_generated_resource from app.services.resource_accounting_service import record_chat_task_generated_resource
from app.tasks.celery_app import celery_app from app.tasks.celery_app import celery_app
@@ -275,9 +276,18 @@ async def enqueue_download_task(
await db.commit() await db.commit()
check_at = _queue_timeout_at(now) check_at = _queue_timeout_at(now)
await _register_active_from_task(task, check_at=check_at, priority=priority, reason=reason) try:
await _register_active_from_task(task, check_at=check_at, priority=priority, reason=reason)
except Exception as exc:
# Redis active 注册表只用于恢复,不应阻止真实 Celery 投递。
await _log_download_event(
task,
event_type=ChatGenerationTaskEventType.DOWNLOAD_ENQUEUE_FAILED,
message=f"下载恢复注册表写入失败: {exc}",
detail={"reason": reason, "celery_task_id": celery_task_id},
)
await _apply_download_async( applied = await _apply_download_async(
task, task,
priority=priority, priority=priority,
countdown=countdown, countdown=countdown,
@@ -285,6 +295,31 @@ async def enqueue_download_task(
event_type=ChatGenerationTaskEventType.DOWNLOAD_RECOVERY_ENQUEUE if recover else ChatGenerationTaskEventType.DOWNLOAD_ENQUEUE, event_type=ChatGenerationTaskEventType.DOWNLOAD_RECOVERY_ENQUEUE if recover else ChatGenerationTaskEventType.DOWNLOAD_ENQUEUE,
failed_event_type=ChatGenerationTaskEventType.DOWNLOAD_RECOVERY_ENQUEUE_FAILED if recover else ChatGenerationTaskEventType.DOWNLOAD_ENQUEUE_FAILED, failed_event_type=ChatGenerationTaskEventType.DOWNLOAD_RECOVERY_ENQUEUE_FAILED if recover else ChatGenerationTaskEventType.DOWNLOAD_ENQUEUE_FAILED,
) )
if not applied:
# apply_async 失败不能伪装成已投递。保留远程结果,进入下载恢复等待。
try:
await remove_download_active(task.id)
except Exception:
pass
refreshed = await _reload_task(db, task.id)
if refreshed and refreshed.status == ChatGenerationTaskStatus.GENERATING.value:
retry_at = now + timedelta(seconds=int(settings.DOWNLOAD_TASK_RETRY_BACKOFF_SECONDS or 30))
refreshed.pipeline_stage = DOWNLOAD_STAGE_RETRY_WAITING
refreshed.download_next_retry_at = retry_at
refreshed.download_last_error = "Celery 下载任务投递失败,等待恢复重试"
refreshed.download_lease_until = None
await db.commit()
try:
await _register_active_from_task(
refreshed,
check_at=retry_at,
priority=settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
reason="enqueue_failed_wait_recovery",
)
except Exception:
pass
return None
if old_stage != DOWNLOAD_STAGE_QUEUED: if old_stage != DOWNLOAD_STAGE_QUEUED:
# 独立记录阶段变化的上下文,便于和真正投递事件对照。 # 独立记录阶段变化的上下文,便于和真正投递事件对照。
await _log_download_event( await _log_download_event(
@@ -466,19 +501,32 @@ async def _mark_download_failed(
non_retryable: bool = False, non_retryable: bool = False,
) -> None: ) -> None:
error_message = extract_error_message(exc, "下载") if callable(extract_error_message) else str(exc) error_message = extract_error_message(exc, "下载") if callable(extract_error_message) else str(exc)
await mark_chat_generation_task_failed_and_refund_once( is_image_child = (
db, task.gen_type == GenerationType.IMAGE.value
task=task, and task.generation_mode == GenerationMode.CHATAPI_CHILD.value
error_message=error_message,
pipeline_stage=DOWNLOAD_STAGE_FAILED,
) )
if is_image_child:
# 图片生成费用属于 main;child 下载失败只记录下载终态,不退图片生成积分。
task.status = ChatGenerationTaskStatus.FAILED.value
task.pipeline_stage = DOWNLOAD_STAGE_FAILED
task.error_message = error_message
await db.flush()
else:
await mark_chat_generation_task_failed_and_refund_once(
db,
task=task,
error_message=error_message,
pipeline_stage=DOWNLOAD_STAGE_FAILED,
)
task.download_last_error = error_message task.download_last_error = error_message
task.download_lease_until = None task.download_lease_until = None
task.download_next_retry_at = None task.download_next_retry_at = None
from app.services.generation_module_hook_service import notify_chat_generation_task_finished from app.services.generation.module_hook_service import notify_chat_generation_task_finished
from app.services.generation.ai.task_group_service import aggregate_parent_for_child
await notify_chat_generation_task_finished(db, task) await notify_chat_generation_task_finished(db, task)
await aggregate_parent_for_child(db, task)
await db.commit() await db.commit()
await remove_download_active(task.id) await remove_download_active(task.id)
@@ -512,7 +560,16 @@ async def _run(task_id: str):
return return
try: try:
downloaded = await download_generation_result(task) use_video_upscale = bool(
task.gen_type == GenerationType.VIDEO.value
and task.video_upscale_enabled_snapshot
and task.video_upscale_snapshot_json
)
downloaded = (
await download_video_upscale_source(task)
if use_video_upscale
else await download_generation_result(task)
)
task = await _reload_task(db, task_id) task = await _reload_task(db, task_id)
if not task: if not task:
@@ -524,6 +581,37 @@ async def _run(task_id: str):
await remove_download_active(task_id) await remove_download_active(task_id)
return return
if use_video_upscale:
from app.services.video_upscale.task_service import prepare_video_upscale_task, enqueue_upscale_task
upscale = await prepare_video_upscale_task(
db,
task=task,
source_local_path=str(downloaded.storage_path or ""),
source_file_size_bytes=downloaded.file_size_bytes,
)
task.download_lease_until = None
task.download_next_retry_at = None
task.download_last_error = None
task.retry_count = 0
await db.commit()
await remove_download_active(task.id)
await enqueue_upscale_task(db, upscale=upscale, reason="source_download_completed")
await _log_download_event(
task,
event_type=ChatGenerationTaskEventType.DOWNLOAD_SUCCESS,
to_status=ChatGenerationTaskStatus.GENERATING.value,
to_stage=ChatGenerationPipelineStage.UPSCALE_QUEUED.value,
detail={
"upscale_source_path": downloaded.storage_path,
"file_size_bytes": downloaded.file_size_bytes,
"download_attempt_count": task.download_attempt_count,
"user_resource_recorded": False,
"cover_generated": False,
},
)
return
if task.gen_type == GenerationType.IMAGE.value: if task.gen_type == GenerationType.IMAGE.value:
task.image_url = downloaded.url task.image_url = downloaded.url
else: else:
@@ -549,9 +637,11 @@ async def _run(task_id: str):
) )
await sync_chat_generation_task_media_token_snapshot(db, task) await sync_chat_generation_task_media_token_snapshot(db, task)
from app.services.generation_module_hook_service import notify_chat_generation_task_finished from app.services.generation.module_hook_service import notify_chat_generation_task_finished
from app.services.generation.ai.task_group_service import aggregate_parent_for_child
await notify_chat_generation_task_finished(db, task) await notify_chat_generation_task_finished(db, task)
await aggregate_parent_for_child(db, task)
await db.commit() await db.commit()
await remove_download_active(task.id) await remove_download_active(task.id)
@@ -19,8 +19,8 @@ from app.enums.generation_task import (
from app.models.base import async_session from app.models.base import async_session
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
from app.services.error_codes import extract_error_message from app.services.error_codes import extract_error_message
from app.services.generation_log_service import log_task_event, log_provider_call from app.services.generation.log_service import log_task_event, log_provider_call
from app.services.generation_poll_schedule_service import ( from app.services.generation.poll_schedule_service import (
build_default_poll_schedule, build_default_poll_schedule,
build_video_pending_poll_schedule, build_video_pending_poll_schedule,
ensure_video_poll_fields, ensure_video_poll_fields,
@@ -28,8 +28,8 @@ from app.services.generation_poll_schedule_service import (
is_poll_not_due, is_poll_not_due,
is_video_generation_task, is_video_generation_task,
) )
from app.services.generation_refund_service import mark_chat_generation_task_failed_and_refund_once from app.services.generation.refund_service import mark_chat_generation_task_failed_and_refund_once
from app.services.generation_provider_service import poll_provider_task from app.services.generation.provider_service import poll_provider_task
from app.services.media_token_usage_snapshot_service import sync_chat_generation_task_media_token_snapshot from app.services.media_token_usage_snapshot_service import sync_chat_generation_task_media_token_snapshot
from app.services.redis_registry_service import ( from app.services.redis_registry_service import (
datetime_to_epoch, datetime_to_epoch,
@@ -144,9 +144,11 @@ async def remove_poll_active(task_id: str) -> None:
async def _notify_finished(db, task: ChatGenerationTask) -> None: async def _notify_finished(db, task: ChatGenerationTask) -> None:
from app.services.generation_module_hook_service import notify_chat_generation_task_finished from app.services.generation.module_hook_service import notify_chat_generation_task_finished
from app.services.generation.ai.task_group_service import aggregate_parent_for_child
await notify_chat_generation_task_finished(db, task) await notify_chat_generation_task_finished(db, task)
await aggregate_parent_for_child(db, task)
async def _reload_task(db, task_id: str) -> ChatGenerationTask | None: async def _reload_task(db, task_id: str) -> ChatGenerationTask | None:

Some files were not shown because too many files have changed in this diff Show More