merge main
This commit is contained in:
Vendored
+89
-89
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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={[
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
支持 MP4、WebM、MOV、GIF、WebP,最大 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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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: '本地 FFmpeg(crop)' },
|
||||||
|
{ 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 ? (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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 ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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/video;media_references[].type=image/video/audio;"
|
"枚举:gen_type=image/video;media_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}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 *
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 字符串值到最近生成记录模块枚举的映射。"""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
+140
-358
@@ -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)
|
||||||
+8
-4
@@ -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,
|
||||||
+40
@@ -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")
|
||||||
+59
-11
@@ -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
-4
@@ -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,
|
||||||
+92
-38
@@ -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:
|
||||||
+133
-4
@@ -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,
|
||||||
+2
-4
@@ -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()
|
||||||
+22
-8
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
Reference in New Issue
Block a user