媒体生成百分比显示

This commit is contained in:
sjy
2026-07-16 17:58:16 +08:00
71 changed files with 6574 additions and 942 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -28,7 +28,7 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-CKURqRU_.js"></script> <script type="module" crossorigin src="/assets/index-CEW5ggCs.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css"> <link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
</head> </head>
<body> <body>
+2
View File
@@ -32,6 +32,7 @@ import AdminShotReplications from './pages/AdminShotReplications';
import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail'; import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail'; import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig'; import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
import AdminVideoUpscale from './pages/AdminVideoUpscale';
import AdminContactRequests from './pages/AdminContactRequests'; import AdminContactRequests from './pages/AdminContactRequests';
import AdminHomeMaterials from './pages/AdminHomeMaterials'; import AdminHomeMaterials from './pages/AdminHomeMaterials';
import AdminPreTestTemplates from './pages/AdminPreTestTemplates'; import AdminPreTestTemplates from './pages/AdminPreTestTemplates';
@@ -98,6 +99,7 @@ const App = () => {
<Route path="payment-stats" element={<AdminPaymentStats />} /> <Route path="payment-stats" element={<AdminPaymentStats />} />
<Route path="settings" element={<AdminSettings />} /> <Route path="settings" element={<AdminSettings />} />
<Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} /> <Route path="video-prompt-schema-config" element={<AdminVideoPromptSchemaConfig />} />
<Route path="video-upscale" element={<AdminVideoUpscale />} />
<Route path="notifications" element={<AdminNotificationManager />} /> <Route path="notifications" element={<AdminNotificationManager />} />
<Route path="oauthapp-list" element={<AdminOauthAppList />} /> <Route path="oauthapp-list" element={<AdminOauthAppList />} />
<Route path="operation-logs" element={<AdminOperationLogs />} /> <Route path="operation-logs" element={<AdminOperationLogs />} />
+33
View File
@@ -17,6 +17,7 @@ import type {
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams, AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut, PrivatePortraitConfig, PrivatePortraitProjectListOut, PrivatePortraitAssetListOut,
AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene, AdminUploadFileResult, AdminUploadResourceType, AdminUploadScene,
VideoUpscaleConfigOut, VideoUpscaleConfigSavePayload,
} from '../types'; } from '../types';
import type { import type {
@@ -41,6 +42,18 @@ import type {
HomeMaterialWatermarkQueryParams, HomeMaterialWatermarkQueryParams,
} from '../types'; } from '../types';
// ── Video Upscale ────────────────────────────────────────
export async function getVideoUpscaleConfig(): Promise<VideoUpscaleConfigOut> {
return api.get<VideoUpscaleConfigOut>('/admin/video-upscale/config');
}
export async function saveVideoUpscaleConfig(payload: VideoUpscaleConfigSavePayload): Promise<VideoUpscaleConfigOut> {
return api.put<VideoUpscaleConfigOut>('/admin/video-upscale/config', payload);
}
// ── Auth ────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> { export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
@@ -224,6 +237,10 @@ export async function updateSystemConfig(id: string, value: string): Promise<voi
await api.put(`/admin/system-configs/${id}`, { value }); await api.put(`/admin/system-configs/${id}`, { value });
} }
export async function createSystemConfig(key: string, value: string, description?: string): Promise<SystemConfig> {
return api.post('/admin/system-configs', { key, value, description });
}
export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> { export async function getGlobalResourceCapacity(): Promise<ResourceCapacityConfigOut> {
return api.get('/admin/resource-capacity/global'); return api.get('/admin/resource-capacity/global');
} }
@@ -276,6 +293,22 @@ export async function uploadLogo(file: File): Promise<{ url: string }> {
return { url: res.url }; return { url: res.url };
} }
export async function uploadLoginVideo(file: File): Promise<{ url: string }> {
const form = new FormData();
form.append('file', file);
const token = localStorage.getItem('auth_token');
const res = await fetch(`${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}/api/admin/upload-login-video`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.detail || '上传失败');
}
return res.json();
}
function setMaybe(params: URLSearchParams, key: string, value: unknown): void { function setMaybe(params: URLSearchParams, key: string, value: unknown): void {
if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value)); if (value !== undefined && value !== null && String(value) !== '') params.set(key, String(value));
} }
@@ -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>
); );
}; };
+223 -71
View File
@@ -1,17 +1,19 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Typography, Upload, Button, Card, Form, Input, InputNumber, message, Select, Space, Switch, Tabs, Typography, Upload,
} from 'antd'; } from 'antd';
import { import {
SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, SettingOutlined, SaveOutlined, UploadOutlined, FilePdfOutlined, EyeOutlined, DatabaseOutlined, VideoCameraOutlined, RobotOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
createSystemConfig,
getGlobalResourceCapacity, getGlobalResourceCapacity,
getSystemConfigs, getSystemConfigs,
saveGlobalResourceCapacity, saveGlobalResourceCapacity,
updateSystemConfig, updateSystemConfig,
uploadLogo, uploadLogo,
uploadPdf, uploadPdf,
uploadLoginVideo,
} from '../api'; } from '../api';
import type { ResourceCapacityUnit, SystemConfig } from '../types'; import type { ResourceCapacityUnit, SystemConfig } from '../types';
@@ -39,6 +41,10 @@ const AdminSettings: React.FC = () => {
getSystemConfigs(), getSystemConfigs(),
getGlobalResourceCapacity(), getGlobalResourceCapacity(),
]); ]);
// 确保 llm_media_as_base64 配置存在
if (!data.some(c => c.key === 'llm_media_as_base64')) {
data.push({ id: 'cfg_llm_media_as_base64', key: 'llm_media_as_base64', value: 'true', description: '文字模型请求时图片/视频使用 base64 编码' });
}
setConfigs(data); setConfigs(data);
const formValues: Record<string, any> = {}; const formValues: Record<string, any> = {};
data.forEach(c => { formValues[c.key] = c.value; }); data.forEach(c => { formValues[c.key] = c.value; });
@@ -118,6 +124,55 @@ const AdminSettings: React.FC = () => {
return false; return false;
}; };
const handleLoginVideoUpload = async (file: File) => {
setUploading('login_bg_video');
try {
const res = await uploadLoginVideo(file);
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: res.url } : c));
form.setFieldsValue({ login_bg_video: res.url });
const config = configs.find(c => c.key === 'login_bg_video');
if (config) {
await updateSystemConfig(config.id, res.url);
}
message.success('登录背景视频上传成功并已保存');
} catch (e: any) {
message.error(e?.message || '上传失败');
} finally {
setUploading('');
}
return false;
};
const handleRemoveLoginVideo = async () => {
setConfigs(prev => prev.map(c => c.key === 'login_bg_video' ? { ...c, value: '' } : c));
form.setFieldsValue({ login_bg_video: '' });
const config = configs.find(c => c.key === 'login_bg_video');
if (config) {
await updateSystemConfig(config.id, '');
}
message.success('已移除登录背景视频');
};
const handleToggleBase64 = async (checked: boolean) => {
try {
let config = configs.find(c => c.key === 'llm_media_as_base64');
if (config && config.id && !config.id.startsWith('cfg_')) {
await updateSystemConfig(config.id, checked ? 'true' : 'false');
} else {
const res = await createSystemConfig('llm_media_as_base64', checked ? 'true' : 'false', '文字模型请求时图片/视频使用 base64 编码');
config = res;
}
setConfigs(prev => {
const exists = prev.some(c => c.key === 'llm_media_as_base64');
if (exists) return prev.map(c => c.key === 'llm_media_as_base64' ? { ...c, value: checked ? 'true' : 'false', id: config!.id } : c);
return [...prev, config!];
});
message.success(`${checked ? '开启' : '关闭'}文字模型媒体 base64 编码`);
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const groupedConfigs: Record<string, SystemConfig[]> = { const groupedConfigs: Record<string, SystemConfig[]> = {
'站点信息': configs.filter(c => c.key.startsWith('site_')), '站点信息': configs.filter(c => c.key.startsWith('site_')),
'协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'), '协议配置': configs.filter(c => c.key === 'user_agreement_privacy_url'),
@@ -278,6 +333,170 @@ const AdminSettings: React.FC = () => {
return <Card loading variant="outlined" style={{ borderRadius: 12 }} />; return <Card loading variant="outlined" style={{ borderRadius: 12 }} />;
} }
const tabItems = [
{
key: 'basic',
label: '网站基础设置',
children: (
<Form form={form} layout="vertical">
{['站点信息', '协议配置', 'SEO 设置'].map(group => (
<div key={group} style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
{group}
</Typography.Text>
{group === '协议配置' ? (
groupedConfigs[group]?.map(config => (
<PdfUploadField key={config.id} config={config} />
))
) : (
groupedConfigs[group]?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))
)}
</div>
))}
</Form>
),
},
{
key: 'credits',
label: '用户积分配置',
children: (
<Form form={form} layout="vertical">
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
{groupedConfigs['用户积分配置']?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))}
</div>
</Form>
),
},
{
key: 'other',
label: '其他配置',
children: (
<Form form={form} layout="vertical">
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
{groupedConfigs['其他配置']?.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))}
</div>
{/* 登录背景视频 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Space>
<VideoCameraOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<Typography.Text strong></Typography.Text>
</Space>
<Space>
{form.getFieldValue('login_bg_video') && (
<Button size="small" danger onClick={handleRemoveLoginVideo}>
</Button>
)}
<Upload
accept="video/*,image/gif,image/webp"
showUploadList={false}
beforeUpload={handleLoginVideoUpload}
>
<Button size="small" type="primary" icon={<UploadOutlined />} loading={uploading === 'login_bg_video'}>
</Button>
</Upload>
</Space>
</div>
{(() => {
const url = form.getFieldValue('login_bg_video');
if (!url) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
使 backimage.png
</Typography.Text>
);
}
const fullUrl = url.startsWith('http') ? url : `${import.meta.env.VITE_API_BASE || 'http://localhost:8000'}${url}`;
const isGif = url.toLowerCase().endsWith('.gif');
return isGif ? (
<img src={fullUrl} alt="预览" style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#f0f0f5', objectFit: 'contain' }} />
) : (
<video
src={fullUrl}
controls
muted
loop
playsInline
style={{ width: '100%', maxHeight: 200, borderRadius: 8, background: '#000' }}
/>
);
})()}
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 6 }}>
MP4WebMMOVGIFWebP 50MB
</Typography.Text>
</div>
</div>
{/* 文字模型媒体编码 */}
<div style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, borderRadius: 10, border: '1px solid #f0f0f5', background: '#fafbfc' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Space>
<RobotOutlined style={{ color: '#6366f1', fontSize: 18 }} />
<div>
<Typography.Text strong>/ base64 </Typography.Text>
<div style={{ color: '#64748b', fontSize: 12, marginTop: 2 }}>
base64 URL
</div>
</div>
</Space>
<Switch
checked={(configs.find(c => c.key === 'llm_media_as_base64') || {}).value === 'true'}
onChange={handleToggleBase64}
checkedChildren="base64"
unCheckedChildren="链接"
/>
</div>
</div>
</div>
</Form>
),
},
];
return ( return (
<div style={{ maxWidth: 720 }}> <div style={{ maxWidth: 720 }}>
<Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}> <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5', marginBottom: 16 }}>
@@ -292,78 +511,11 @@ const AdminSettings: React.FC = () => {
</div> </div>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">SEO配置</Typography.Text> <Typography.Text type="secondary"></Typography.Text>
</div> </div>
</div> </div>
<Form form={form} layout="vertical"> <Tabs items={tabItems} defaultActiveKey="basic" />
{Object.entries(groupedConfigs).map(([group, items]) => (
<div key={group} style={{ marginBottom: 24 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
{group}
</Typography.Text>
{group === '协议配置' ? (
items.map(config => (
<PdfUploadField key={config.id} config={config} />
))
) : (
items.map(config => (
<Form.Item
key={config.id}
name={config.key}
label={<span style={{ fontWeight: 500 }}>{config.description}</span>}
extra={getFieldDescription(config)}
>
{getFieldComponent(config)}
</Form.Item>
))
)}
</div>
))}
<div style={{ marginBottom: 4 }}>
<Typography.Text strong style={{ fontSize: 14, display: 'block', marginBottom: 12, paddingBottom: 8, borderBottom: '1px solid #f0f0f5' }}>
</Typography.Text>
<div style={{ padding: 16, border: '1px solid #f0f0f5', borderRadius: 10, background: '#fafbfc' }}>
<Space align="start" style={{ marginBottom: 16 }}>
<DatabaseOutlined style={{ color: '#6366f1', fontSize: 18, marginTop: 2 }} />
<div>
<Typography.Text strong></Typography.Text>
<div style={{ color: '#64748b', fontSize: 13, marginTop: 4 }}>
</div>
</div>
</Space>
<Form.Item
name="resource_capacity_enabled"
label="启用全局容量管控"
valuePropName="checked"
extra="关闭时全局不限制;若用户设置了个人配置,则仍按用户个人配置优先判断。"
>
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 180px', gap: 16 }}>
<Form.Item
name="resource_capacity_limit_value"
label="容量数值"
extra="最小为1,不能为负数,最多支持3位小数。"
rules={[{ required: true, message: '请输入容量数值' }]}
>
<InputNumber min={1} precision={3} style={{ width: '100%' }} size="large" placeholder="例如 10.500" />
</Form.Item>
<Form.Item
name="resource_capacity_limit_unit"
label="容量单位"
extra="MB / GB / TB 固定枚举"
rules={[{ required: true, message: '请选择容量单位' }]}
>
<Select size="large" options={capacityUnitOptions} />
</Form.Item>
</div>
</div>
</div>
</Form>
</Card> </Card>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}> <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd'; import { Button, Card, DatePicker, Input, Progress, Select, Space, Table, Tag, Tooltip, Typography, message } from 'antd';
import { EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons'; import { CameraOutlined, EyeOutlined, ReloadOutlined, SearchOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getAdminShotTaskSets } from '../api'; import { getAdminShotTaskSets } from '../api';
import type { ShotTaskSetOut } from '../types'; import type { ShotTaskSetOut } from '../types';
@@ -134,16 +134,15 @@ const AdminShotReplications: React.FC = () => {
}; };
return ( return (
<div style={{ padding: 24 }}> <Card variant="outlined" style={{ borderRadius: 12, border: '1px solid #f0f0f5' }}>
<Card> <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16, flexWrap: 'wrap', gap: 12 }}>
<Space direction="vertical" size={16} style={{ width: '100%' }}> <Space>
<Space align="center" style={{ justifyContent: 'space-between', width: '100%' }}> <CameraOutlined style={{ fontSize: 18, color: '#6366f1' }} />
<div> <Typography.Text strong style={{ fontSize: 16 }}></Typography.Text>
<Typography.Title level={3} style={{ marginBottom: 4 }}></Typography.Title> <Tag color="purple">{total} </Tag>
<Typography.Text type="secondary">AI </Typography.Text> </Space>
</div>
<Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button> <Button icon={<ReloadOutlined />} onClick={() => setReloadKey(v => v + 1)}></Button>
</Space> </div>
<Space wrap> <Space wrap>
<Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} /> <Select allowClear placeholder="总任务状态" style={{ width: 150 }} value={status || undefined} onChange={v => { setStatus(v || ''); setPage(1); }} options={TASK_STATUS_OPTIONS} />
@@ -211,9 +210,7 @@ const AdminShotReplications: React.FC = () => {
}, },
]} ]}
/> />
</Space>
</Card> </Card>
</div>
); );
}; };
+1
View File
@@ -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>
), ),
@@ -0,0 +1,262 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
App,
Button,
Card,
Col,
Empty,
Row,
Select,
Space,
Spin,
Switch,
Tag,
Typography,
} from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons';
import { getVideoUpscaleConfig, saveVideoUpscaleConfig } from '../api';
import type {
VideoUpscaleConfigData,
VideoUpscaleProcessorKey,
VideoUpscaleResolutionRule,
} from '../types';
const { Title, Text, Paragraph } = Typography;
const PROCESSORS: Array<{ key: VideoUpscaleProcessorKey; label: string }> = [
{ key: 'local_ffmpeg_crop_v1', label: '本地 FFmpegcrop' },
{ key: 'volc_standard_v1', label: '火山画质增强(标准版)' },
{ key: 'volc_professional_v1', label: '火山画质增强(专业版)' },
{ key: 'volc_large_model_v1', label: '火山画质增强(大模型)' },
];
const RESOLUTION_OPTIONS = ['480p', '720p', '1080p', '2K', '4K'].map((value) => ({
label: value,
value,
}));
const defaultRule = (): VideoUpscaleResolutionRule => ({
targetResolution: '1080p',
providerGenerationResolution: '720p',
processorKey: 'local_ffmpeg_crop_v1',
enabled: true,
});
function normalizeConfig(data: VideoUpscaleConfigData): VideoUpscaleConfigData {
return {
enabled: !!data.enabled,
version: Number(data.version || 1),
deleteSourceAfterSuccess: data.deleteSourceAfterSuccess !== false,
rules: Array.isArray(data.rules) ? data.rules.map((rule) => ({ ...rule })) : [],
};
}
function toSavePayload(data: VideoUpscaleConfigData) {
return {
data: {
enabled: data.enabled,
version: data.version,
delete_source_after_success: data.deleteSourceAfterSuccess,
rules: data.rules.map((rule) => ({
target_resolution: rule.targetResolution,
provider_generation_resolution: rule.providerGenerationResolution,
processor_key: rule.processorKey,
enabled: rule.enabled,
})),
},
};
}
const AdminVideoUpscale: React.FC = () => {
const { message, modal } = App.useApp();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [config, setConfig] = useState<VideoUpscaleConfigData | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await getVideoUpscaleConfig();
setConfig(normalizeConfig(result.data));
} catch (error) {
message.error(error instanceof Error ? error.message : '读取视频超分配置失败');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => {
void load();
}, [load]);
const updateRule = (index: number, patch: Partial<VideoUpscaleResolutionRule>) => {
setConfig((current) => {
if (!current) return current;
return {
...current,
rules: current.rules.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item),
};
});
};
const removeRule = (index: number) => {
setConfig((current) => current ? {
...current,
rules: current.rules.filter((_, itemIndex) => itemIndex !== index),
} : current);
};
const addRule = () => {
setConfig((current) => current ? { ...current, rules: [...current.rules, defaultRule()] } : current);
};
const save = async () => {
if (!config) return;
const targets = config.rules
.filter((item) => item.enabled)
.map((item) => item.targetResolution.trim().toLowerCase());
if (new Set(targets).size !== targets.length) {
message.error('同一个客户目标分辨率只能存在一条启用规则');
return;
}
setSaving(true);
try {
const result = await saveVideoUpscaleConfig(toSavePayload(config));
setConfig(normalizeConfig(result.data));
message.success(`视频超分配置已保存,版本 ${result.data.version}`);
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败');
} finally {
setSaving(false);
}
};
if (loading || !config) {
return (
<div style={{ minHeight: 360, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="large" />
</div>
);
}
return (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card>
<Row justify="space-between" align="middle" gutter={[16, 16]}>
<Col>
<Title level={3} style={{ margin: 0 }}></Title>
<Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
</Paragraph>
</Col>
<Col>
<Space>
<Tag> {config.version}</Tag>
<Button icon={<ReloadOutlined />} onClick={() => void load()}></Button>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void save()}>
</Button>
</Space>
</Col>
</Row>
<Space direction="vertical" size={14} style={{ marginTop: 20 }}>
<Space>
<Text strong></Text>
<Switch
checked={config.enabled}
onChange={(enabled) => setConfig({ ...config, enabled })}
/>
<Text type="secondary"></Text>
</Space>
<Space>
<Text strong></Text>
<Switch
checked={config.deleteSourceAfterSuccess}
onChange={(deleteSourceAfterSuccess) => setConfig({ ...config, deleteSourceAfterSuccess })}
/>
<Text type="secondary">
</Text>
</Space>
</Space>
</Card>
<Card
title="目标分辨率规则"
extra={<Button icon={<PlusOutlined />} onClick={addRule}></Button>}
>
{config.rules.length === 0 ? (
<Empty description="暂无规则;未匹配规则的视频任务继续走原流程" />
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{config.rules.map((rule, index) => {
const duplicate = rule.enabled && config.rules.filter(
(item) => item.enabled && item.targetResolution.toLowerCase() === rule.targetResolution.toLowerCase(),
).length > 1;
return (
<Card key={`${index}-${rule.targetResolution}`} size="small">
<Row gutter={[12, 12]} align="bottom">
<Col xs={24} md={5}>
<Text type="secondary"></Text>
<Select
value={rule.targetResolution}
options={RESOLUTION_OPTIONS}
style={{ width: '100%', marginTop: 4 }}
onChange={(value) => updateRule(index, { targetResolution: value })}
status={duplicate ? 'error' : undefined}
/>
</Col>
<Col xs={24} md={5}>
<Text type="secondary"></Text>
<Select
value={rule.providerGenerationResolution}
options={RESOLUTION_OPTIONS}
style={{ width: '100%', marginTop: 4 }}
onChange={(value) => updateRule(index, { providerGenerationResolution: value })}
/>
</Col>
<Col xs={24} md={7}>
<Text type="secondary"></Text>
<Select
value={rule.processorKey}
options={PROCESSORS.map((item) => ({ value: item.key, label: item.label }))}
style={{ width: '100%', marginTop: 4 }}
onChange={(value) => updateRule(index, { processorKey: value })}
/>
</Col>
<Col xs={12} md={2}>
<Text type="secondary"></Text>
<div style={{ marginTop: 8 }}>
<Switch checked={rule.enabled} onChange={(enabled) => updateRule(index, { enabled })} />
</div>
</Col>
<Col xs={12} md={2}>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => modal.confirm({
title: '删除这条超分规则?',
onOk: () => removeRule(index),
})}
/>
</Col>
<Col span={24}>
<Text type="secondary">
</Text>
</Col>
</Row>
</Card>
);
})}
</Space>
)}
</Card>
</Space>
);
};
export default AdminVideoUpscale;
+45
View File
@@ -1308,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;
}>;
};
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/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/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"} {"root":["./src/app.tsx","./src/env.d.ts","./src/main.tsx","./src/api/client.ts","./src/api/crypto.ts","./src/api/index.ts","./src/components/preresultdisplay.tsx","./src/components/generation/generationtaskresourcegrid.tsx","./src/pages/adminauthoriz.tsx","./src/pages/adminconsume.tsx","./src/pages/admincontactrequests.tsx","./src/pages/admincreditratios.tsx","./src/pages/admincreditrecords.tsx","./src/pages/admindashboard.tsx","./src/pages/admingenerationairecords.tsx","./src/pages/admingenerationrecords.tsx","./src/pages/adminhomematerials.tsx","./src/pages/adminhotopeningreplicationdetail.tsx","./src/pages/adminhotopeningreplications.tsx","./src/pages/adminimageengines.tsx","./src/pages/adminindustries.tsx","./src/pages/adminlayout.tsx","./src/pages/adminloginpage.tsx","./src/pages/adminmateriallist.tsx","./src/pages/adminmenuconfig.tsx","./src/pages/adminmodels.tsx","./src/pages/adminnotificationmanager.tsx","./src/pages/adminoauthlist.tsx","./src/pages/adminoauthapplist.tsx","./src/pages/adminoperationlogs.tsx","./src/pages/adminpaymentconfig.tsx","./src/pages/adminpaymentstats.tsx","./src/pages/adminplatform.tsx","./src/pages/adminpretesttemplates.tsx","./src/pages/adminprivateportraitprojects.tsx","./src/pages/adminrechargepackages.tsx","./src/pages/adminreplicationprojectdetail.tsx","./src/pages/adminsettings.tsx","./src/pages/adminshotreplications.tsx","./src/pages/adminshottasksetdetail.tsx","./src/pages/adminteams.tsx","./src/pages/adminusers.tsx","./src/pages/adminvideoengines.tsx","./src/pages/adminvideopromptschemaconfig.tsx","./src/pages/adminvideoupscale.tsx","./src/pages/adminreplication/components/jsoncollapse.tsx","./src/pages/adminreplication/components/mediapreview.tsx","./src/pages/adminreplication/components/statustag.tsx","./src/pages/adminreplication/components/videopromptschemaviewer.tsx","./src/pages/homematerials/homematerialassettable.tsx","./src/pages/homematerials/homematerialcategorypanel.tsx","./src/pages/homematerials/homematerialuploadmodal.tsx","./src/pages/homematerials/mediareferenceseditor.tsx","./src/pages/homematerials/watermarkeditor.tsx","./src/pages/homematerials/watermarklibrarymodal.tsx","./src/pages/homematerials/watermarkpreview.tsx","./src/store/index.ts","./src/types/index.ts","./src/types/xlsx-js-style.d.ts","./src/utils/clipboard.ts","./src/utils/excelexport.ts","./src/utils/formatdate.ts","./src/utils/resourceurl.ts","./src/utils/videopromptschema.ts"],"version":"6.0.3"}
+1
View File
@@ -58,6 +58,7 @@ VIDEO_COVER_TIMEOUT_SECONDS=15
VIDEO_COVER_FORMAT=png VIDEO_COVER_FORMAT=png
# VOLC # VOLC
VOLC_API_KEY=AKLTOWMwMjVhNzg0OGE2NDMwZWJkYWIyNzM3ZmMxMjc5NTQ
VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM VOLC_ACCESS_KEY_ID=AKLTYWY5Yjc5YjM3N2IwNDc3M2I3NTU2YjlmNTczYzQzMmM
VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ== VOLC_SECRET_ACCESS_KEY=TXpjM01HUTFZMlV5TUdKbE5Ea3lNRGhqTUdSak16UTFOV0ptTW1SaE5XRQ==
@@ -0,0 +1,180 @@
"""add video upscale pipeline
Revision ID: 3f47680a71d0
Revises: abae3e1c70f7
Create Date: 2026-07-16 11:08:16.449960
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "3f47680a71d0"
down_revision: Union[str, None] = "abae3e1c70f7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"chat_generation_tasks",
sa.Column("provider_generation_resolution", sa.String(length=16), nullable=True),
)
op.add_column(
"chat_generation_tasks",
sa.Column(
"video_upscale_enabled_snapshot",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"chat_generation_tasks",
sa.Column("video_upscale_snapshot_json", sa.Text(), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("provider_generation_resolution", sa.String(length=16), nullable=True),
)
op.add_column(
"generation_records",
sa.Column(
"video_upscale_enabled_snapshot",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
)
op.add_column(
"generation_records",
sa.Column("video_upscale_snapshot_json", sa.Text(), nullable=True),
)
op.add_column(
"generation_records",
sa.Column("pipeline_stage", sa.String(length=48), nullable=True),
)
op.create_index(
"ix_generation_records_pipeline_stage",
"generation_records",
["pipeline_stage"],
unique=False,
)
op.create_table(
"video_upscale_tasks",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("chat_generation_task_id", sa.String(length=32), nullable=True),
sa.Column("generation_record_id", sa.String(length=32), nullable=True),
sa.Column("status", sa.String(length=32), server_default="pending", nullable=False),
sa.Column("stage", sa.String(length=48), server_default="upscale_queued", nullable=False),
sa.Column("processor_key", sa.String(length=64), nullable=False),
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("failure_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("manual_retry_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text(), nullable=True),
sa.Column("source_local_path", sa.Text(), nullable=True),
sa.Column("source_file_size_bytes", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("source_width", sa.Integer(), nullable=True),
sa.Column("source_height", sa.Integer(), nullable=True),
sa.Column("source_duration_seconds", sa.Float(), nullable=True),
sa.Column("source_deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_delete_error", sa.Text(), nullable=True),
sa.Column("source_remote_url", sa.Text(), nullable=True),
sa.Column("source_remote_url_signed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_remote_url_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_remote_url_last_probe_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("source_remote_url_probe_status", sa.String(length=32), nullable=True),
sa.Column("input_source_type", sa.String(length=32), nullable=True),
sa.Column("input_source_fallback_count", sa.Integer(), server_default="0", nullable=False),
sa.Column("target_width", sa.Integer(), nullable=False),
sa.Column("target_height", sa.Integer(), nullable=False),
sa.Column("effective_target_width", sa.Integer(), nullable=True),
sa.Column("effective_target_height", sa.Integer(), nullable=True),
sa.Column("provider_task_id", sa.String(length=160), nullable=True),
sa.Column("provider_request_json", sa.Text(), nullable=True),
sa.Column("provider_response_json", sa.Text(), nullable=True),
sa.Column("provider_output_url", sa.Text(), nullable=True),
sa.Column("provider_output_url_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("provider_submitted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("final_local_path", sa.Text(), nullable=True),
sa.Column("final_resource_url", sa.Text(), nullable=True),
sa.Column("final_file_size_bytes", sa.BigInteger(), server_default="0", nullable=False),
sa.Column("celery_task_id", sa.String(length=160), nullable=True),
sa.Column("lease_token", sa.String(length=64), nullable=True),
sa.Column("lease_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("failed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.CheckConstraint(
"(chat_generation_task_id IS NOT NULL AND generation_record_id IS NULL) OR "
"(chat_generation_task_id IS NULL AND generation_record_id IS NOT NULL)",
name="ck_video_upscale_tasks_exactly_one_owner",
),
sa.ForeignKeyConstraint(
["chat_generation_task_id"],
["chat_generation_tasks.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["generation_record_id"],
["generation_records.id"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"idx_video_upscale_tasks_provider_task_id",
"video_upscale_tasks",
["provider_task_id"],
unique=False,
)
op.create_index(
"idx_video_upscale_tasks_status_lease",
"video_upscale_tasks",
["status", "lease_until"],
unique=False,
)
op.create_index(
"idx_video_upscale_tasks_status_next_retry",
"video_upscale_tasks",
["status", "next_retry_at"],
unique=False,
)
op.create_index(
"uq_video_upscale_tasks_chat_task",
"video_upscale_tasks",
["chat_generation_task_id"],
unique=True,
)
op.create_index(
"uq_video_upscale_tasks_generation_record",
"video_upscale_tasks",
["generation_record_id"],
unique=True,
)
def downgrade() -> None:
op.drop_index("uq_video_upscale_tasks_generation_record", table_name="video_upscale_tasks")
op.drop_index("uq_video_upscale_tasks_chat_task", table_name="video_upscale_tasks")
op.drop_index("idx_video_upscale_tasks_status_next_retry", table_name="video_upscale_tasks")
op.drop_index("idx_video_upscale_tasks_status_lease", table_name="video_upscale_tasks")
op.drop_index("idx_video_upscale_tasks_provider_task_id", table_name="video_upscale_tasks")
op.drop_table("video_upscale_tasks")
op.drop_index("ix_generation_records_pipeline_stage", table_name="generation_records")
op.drop_column("generation_records", "pipeline_stage")
op.drop_column("generation_records", "video_upscale_snapshot_json")
op.drop_column("generation_records", "video_upscale_enabled_snapshot")
op.drop_column("generation_records", "provider_generation_resolution")
op.drop_column("chat_generation_tasks", "video_upscale_snapshot_json")
op.drop_column("chat_generation_tasks", "video_upscale_enabled_snapshot")
op.drop_column("chat_generation_tasks", "provider_generation_resolution")
+2
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
from app.api.admin.video_upscale import router as video_upscale_router
from app.api.admin.resource_capacity import router as resource_capacity_router from app.api.admin.resource_capacity import router as resource_capacity_router
from app.api.admin.team import router as team_router from app.api.admin.team import router as team_router
from app.api.admin.home_material import router as home_material_router from app.api.admin.home_material import router as home_material_router
@@ -11,6 +12,7 @@ from app.api.admin.upload import router as admin_upload_router
router = APIRouter() router = APIRouter()
router.include_router(video_prompt_schema_config_router) router.include_router(video_prompt_schema_config_router)
router.include_router(video_upscale_router)
router.include_router(resource_capacity_router) router.include_router(resource_capacity_router)
router.include_router(team_router) router.include_router(team_router)
router.include_router(home_material_router) router.include_router(home_material_router)
@@ -0,0 +1,53 @@
from __future__ import annotations
import json
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_admin_user, get_db
from app.models.user import User
from app.schemas.video_upscale import VideoUpscaleConfigOut, VideoUpscaleConfigSaveRequest
from app.services.operation_log import log_operation
from app.services.video_upscale.config_service import get_video_upscale_config, save_video_upscale_config
router = APIRouter(prefix="/admin/video-upscale", tags=["admin-video-upscale"])
@router.get("/config", response_model=VideoUpscaleConfigOut, summary="获取视频超分配置")
async def get_config(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
_ = admin
return await get_video_upscale_config(db)
@router.put("/config", response_model=VideoUpscaleConfigOut, summary="保存视频超分配置")
async def save_config(
req: VideoUpscaleConfigSaveRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
before = await get_video_upscale_config(db)
result = await save_video_upscale_config(db, req.data)
await log_operation(
db,
admin.id,
admin.username,
"保存视频超分配置",
"PUT",
"/admin/video-upscale/config",
detail=json.dumps(
{
"before_version": before["data"].get("version"),
"after_version": result["data"].get("version"),
"enabled": result["data"].get("enabled"),
"delete_source_after_success": result["data"].get("delete_source_after_success"),
"rule_count": len(result["data"].get("rules") or []),
},
ensure_ascii=False,
),
)
await db.commit()
return result
+112 -2
View File
@@ -24,10 +24,12 @@ from app.models.credit_ratio import CreditRatio
from app.models.operation_log import OperationLog from app.models.operation_log import OperationLog
from app.enums.user import FrontendUserKind, UserType from app.enums.user import FrontendUserKind, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.enums.generation_status import GenerationRecordPipelineStage
from app.schemas.admin import ( from app.schemas.admin import (
CreditAdjustRequest, CreditAdjustRequest,
ModelConfigCreate, ModelConfigCreate,
ModelConfigOut, ModelConfigOut,
SystemConfigCreate,
SystemConfigUpdate, SystemConfigUpdate,
SystemConfigOut, SystemConfigOut,
AdminUserOut, AdminUserOut,
@@ -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,
@@ -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,
@@ -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 ────────────────────────────────────────
+2 -1
View File
@@ -342,7 +342,7 @@ 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()
@@ -365,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 "",
} }
+104 -58
View File
@@ -41,6 +41,7 @@ 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.enums.generation_status import GenerationRecordPipelineStage
from app.services.generation.billing_service import ( from app.services.generation.billing_service import (
CHARGE_TEXT_PROMPT, CHARGE_TEXT_PROMPT,
OWNER_GENERATION_RECORD, OWNER_GENERATION_RECORD,
@@ -101,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 '',
@@ -416,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)
@@ -432,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,
@@ -448,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
@@ -458,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,
@@ -470,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,
@@ -502,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:
@@ -543,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)
@@ -551,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,
@@ -572,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,
@@ -581,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,
@@ -679,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,
} }
@@ -705,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(CST)
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,
+14
View File
@@ -761,6 +761,20 @@ async def retry_task(
raise HTTPException(status_code=400, detail="只有失败任务可以重试") raise HTTPException(status_code=400, detail="只有失败任务可以重试")
retry_targets = [task] 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] = [] enqueue_ids: list[str] = []
download_retry_ids: list[str] = [] download_retry_ids: list[str] = []
+5
View File
@@ -10,6 +10,7 @@ from app.models.project import Project
from app.models.generation_record import GenerationRecord from app.models.generation_record import GenerationRecord
from app.schemas.project import ProjectCreate, ProjectOut from app.schemas.project import ProjectCreate, ProjectOut
from app.services.resource_accounting_service import soft_delete_generation_record_resources from app.services.resource_accounting_service import soft_delete_generation_record_resources
from app.services.video_upscale.guard_service import assert_no_recoverable_failed_upscale_tasks
from app.utils.id_gen import generate_id from app.utils.id_gen import generate_id
router = APIRouter(prefix="/projects", tags=["projects"]) router = APIRouter(prefix="/projects", tags=["projects"])
@@ -88,6 +89,10 @@ async def delete_project(
) )
records = list(records_result.scalars().all()) records = list(records_result.scalars().all())
record_ids = [record.id for record in records] record_ids = [record.id for record in records]
await assert_no_recoverable_failed_upscale_tasks(
db,
generation_record_ids=record_ids,
)
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
project.deleted_at = now project.deleted_at = now
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,188 @@
from __future__ import annotations
import argparse
import asyncio
import json
from datetime import datetime, timezone
from sqlalchemy import or_, select
from app.config import settings
from app.enums.video_upscale import LOCAL_PROCESSOR_KEYS, VideoUpscaleTaskStatus
from app.models.base import async_session
from app.models.chat_generation_task import ChatGenerationTask
from app.models.generation_record import GenerationRecord
from app.models.module_generation_step import ModuleGenerationStep
from app.models.shot_replicate_segment import ShotReplicateSegment
from app.models.video_upscale_task import VideoUpscaleTask
from app.services.video_upscale.media_service import is_valid_file
from app.services.video_upscale.task_service import reset_failed_upscale_task_for_manual_retry
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="人工恢复视频超分任务")
parser.add_argument("--task-id", action="append", default=[], help="ChatGenerationTask.id,可重复传入")
parser.add_argument("--task-ids", default="", help="逗号分隔的 ChatGenerationTask.id")
parser.add_argument("--generation-record-id", action="append", default=[], help="GenerationRecord.id,可重复传入")
parser.add_argument("--generation-record-ids", default="", help="逗号分隔的 GenerationRecord.id")
parser.add_argument("--project-id", action="append", default=[], help="Project.id,可重复传入")
parser.add_argument("--generation-mode", default="", help="按 ChatGenerationTask.generation_mode 筛选")
parser.add_argument("--module-owner-id", default="", help="ModuleGenerationProject.id")
parser.add_argument("--shot-task-set-id", default="", help="ShotReplicateTaskSet.id")
parser.add_argument("--shot-segment-id", action="append", default=[], help="ShotReplicateSegment.id,可重复传入")
parser.add_argument("--failed-only", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--limit", type=int, default=100)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--enqueue", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--force-resubmit", action="store_true", help="远程任务清空 provider task/result 后从 source.mp4 重新提交")
return parser
async def _collect_chat_task_ids(db, args: argparse.Namespace) -> list[str]:
ids = [str(item).strip() for item in args.task_id if str(item).strip()]
ids.extend(item.strip() for item in str(args.task_ids or "").split(",") if item.strip())
project_ids: list[str] = [str(args.module_owner_id).strip()] if args.module_owner_id else []
segment_ids = [str(item).strip() for item in args.shot_segment_id if str(item).strip()]
if args.shot_task_set_id or segment_ids:
query = select(ShotReplicateSegment.module_project_id).where(
ShotReplicateSegment.deleted_at.is_(None),
ShotReplicateSegment.module_project_id.is_not(None),
)
if args.shot_task_set_id:
query = query.where(ShotReplicateSegment.task_set_id == str(args.shot_task_set_id).strip())
if segment_ids:
query = query.where(ShotReplicateSegment.id.in_(segment_ids))
result = await db.execute(query)
project_ids.extend(str(value) for value in result.scalars().all() if value)
project_ids = list(dict.fromkeys(item for item in project_ids if item))
if project_ids:
result = await db.execute(
select(ModuleGenerationStep.chat_task_id).where(
ModuleGenerationStep.project_id.in_(project_ids),
ModuleGenerationStep.chat_task_id.isnot(None),
ModuleGenerationStep.deleted_at.is_(None),
)
)
ids.extend(str(value) for value in result.scalars().all() if value)
return list(dict.fromkeys(ids))
async def _collect_generation_record_ids(db, args: argparse.Namespace) -> list[str]:
ids = [str(item).strip() for item in args.generation_record_id if str(item).strip()]
ids.extend(item.strip() for item in str(args.generation_record_ids or "").split(",") if item.strip())
project_ids = [str(item).strip() for item in args.project_id if str(item).strip()]
if project_ids:
result = await db.execute(
select(GenerationRecord.id).where(
GenerationRecord.project_id.in_(project_ids),
GenerationRecord.deleted_at.is_(None),
)
)
ids.extend(str(value) for value in result.scalars().all() if value)
return list(dict.fromkeys(ids))
def _owner_preview(upscale: VideoUpscaleTask, chat: ChatGenerationTask | None, record: GenerationRecord | None) -> dict:
owner = chat or record
return {
"upscale_task_id": upscale.id,
"owner_type": "chat_generation_task" if chat else "generation_record",
"owner_id": owner.id if owner else None,
"chat_task_id": chat.id if chat else None,
"generation_record_id": record.id if record else None,
"project_id": record.project_id if record else None,
"generation_mode": chat.generation_mode if chat else None,
"status": upscale.status,
"stage": upscale.stage,
"processor_key": upscale.processor_key,
"source_local_path": upscale.source_local_path,
"provider_task_id": upscale.provider_task_id,
"provider_output_url_expires_at": upscale.provider_output_url_expires_at,
}
async def _run(args: argparse.Namespace) -> dict:
async with async_session() as db:
chat_ids = await _collect_chat_task_ids(db, args)
record_ids = await _collect_generation_record_ids(db, args)
query = (
select(VideoUpscaleTask, ChatGenerationTask, GenerationRecord)
.outerjoin(ChatGenerationTask, ChatGenerationTask.id == VideoUpscaleTask.chat_generation_task_id)
.outerjoin(GenerationRecord, GenerationRecord.id == VideoUpscaleTask.generation_record_id)
.where(
or_(
(ChatGenerationTask.id.isnot(None) & ChatGenerationTask.deleted_at.is_(None)),
(GenerationRecord.id.isnot(None) & GenerationRecord.deleted_at.is_(None)),
)
)
.order_by(VideoUpscaleTask.updated_at.asc())
.limit(max(1, min(int(args.limit or 100), 1000)))
)
owner_filters = []
if chat_ids:
owner_filters.append(VideoUpscaleTask.chat_generation_task_id.in_(chat_ids))
if record_ids:
owner_filters.append(VideoUpscaleTask.generation_record_id.in_(record_ids))
if owner_filters:
query = query.where(or_(*owner_filters))
if args.generation_mode:
query = query.where(ChatGenerationTask.generation_mode == args.generation_mode)
if args.failed_only:
query = query.where(VideoUpscaleTask.status == VideoUpscaleTaskStatus.FAILED.value)
result = await db.execute(query)
rows = result.all()
preview = [_owner_preview(upscale, chat, record) for upscale, chat, record in rows]
if args.dry_run or not args.enqueue:
return {"dry_run": True, "matched": len(preview), "items": preview}
from app.tasks.video_upscale_tasks import download_remote_result, execute_local, finalize, poll_remote, submit_remote
enqueued = []
for upscale, chat, record in rows:
reset = await reset_failed_upscale_task_for_manual_retry(
db,
upscale_task_id=upscale.id,
force_resubmit=bool(args.force_resubmit),
)
if is_valid_file(reset.final_local_path) and not args.force_resubmit:
action = "finalize"
finalize.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_LOCAL_QUEUE)
elif reset.processor_key in LOCAL_PROCESSOR_KEYS:
action = "local"
execute_local.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_LOCAL_QUEUE)
else:
expires_at = reset.provider_output_url_expires_at
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
remaining = (expires_at - datetime.now(timezone.utc)).total_seconds() if expires_at else None
if reset.provider_output_url and remaining is not None and remaining >= 2 * 3600 and not args.force_resubmit:
action = "download"
download_remote_result.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
elif reset.provider_task_id and not args.force_resubmit:
action = "poll"
poll_remote.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
else:
action = "submit"
submit_remote.apply_async(args=[reset.id], queue=settings.VIDEO_UPSCALE_REMOTE_QUEUE)
owner = chat or record
enqueued.append(
{
"upscale_task_id": reset.id,
"owner_type": "chat_generation_task" if chat else "generation_record",
"owner_id": owner.id if owner else None,
"action": action,
}
)
return {"dry_run": False, "matched": len(preview), "enqueued": enqueued}
def main() -> None:
args = _parser().parse_args()
result = asyncio.run(_run(args))
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
if __name__ == "__main__":
main()
+23
View File
@@ -25,6 +25,7 @@ class Settings(BaseSettings):
LLM_API_KEY: str = "" LLM_API_KEY: str = ""
LLM_MODEL: str = "gpt-4o" LLM_MODEL: str = "gpt-4o"
LLM_MOCK: bool = True LLM_MOCK: bool = True
LLM_MEDIA_AS_BASE64: bool = True
ENCRYPTION_KEY: str = "changeme-32bytes-base64-key-here!!" ENCRYPTION_KEY: str = "changeme-32bytes-base64-key-here!!"
@@ -95,6 +96,28 @@ class Settings(BaseSettings):
# - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。 # - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。
# - VIDEO_COVER_TIMEOUT_SECONDS 必须较短,避免 ffmpeg 异常卡住下载 worker。 # - VIDEO_COVER_TIMEOUT_SECONDS 必须较短,避免 ffmpeg 异常卡住下载 worker。
FFMPEG_BIN: str = "" FFMPEG_BIN: str = ""
# 视频超分配置。
# 本地处理器始终复用 FFMPEG_BIN,不允许由管理后台覆盖可执行文件路径。
VOLC_API_KEY: str = ""
VOLC_MEDIAKIT_API_BASE: str = "https://mediakit.cn-beijing.volces.com"
VIDEO_UPSCALE_LOCAL_QUEUE: str = "gen_video_upscale_local"
VIDEO_UPSCALE_REMOTE_QUEUE: str = "gen_video_upscale_remote"
VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS: int = 3600
VIDEO_UPSCALE_MAX_ATTEMPTS: int = 3
VIDEO_UPSCALE_REMOTE_POLL_INTERVAL_SECONDS: int = 30
VIDEO_UPSCALE_REMOTE_POLL_TIMEOUT_SECONDS: int = 7200
VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS: int = 30
VIDEO_UPSCALE_REMOTE_RESULT_DOWNLOAD_TIMEOUT_SECONDS: int = 600
VIDEO_UPSCALE_REMOTE_URL_PROBE_THRESHOLD_SECONDS: int = 600
VIDEO_UPSCALE_REMOTE_URL_PROBE_CONNECT_TIMEOUT_SECONDS: int = 3
VIDEO_UPSCALE_REMOTE_URL_PROBE_READ_TIMEOUT_SECONDS: int = 5
VIDEO_UPSCALE_LOCAL_SIGNED_URL_EXPIRE_SECONDS: int = 7200
VIDEO_UPSCALE_TASK_LEASE_SECONDS: int = 30 * 60
VIDEO_UPSCALE_RETRY_BACKOFF_SECONDS: int = 60
VIDEO_UPSCALE_RECOVERY_BATCH_SIZE: int = 50
VIDEO_UPSCALE_RECOVERY_LOCK_KEY: str = "vg:celery:video_upscale_recovery_lock"
VIDEO_COVER_SEEK_TIME: str = "00:00:01" VIDEO_COVER_SEEK_TIME: str = "00:00:01"
VIDEO_COVER_FALLBACK_SEEK_TIME: str = "00:00:00" VIDEO_COVER_FALLBACK_SEEK_TIME: str = "00:00:00"
VIDEO_COVER_WIDTH: int = 720 VIDEO_COVER_WIDTH: int = 720
+8
View File
@@ -5,6 +5,8 @@ class CeleryQueue(str, Enum):
GEN_CHATAPI_CREATE = "gen_chatapi_create" GEN_CHATAPI_CREATE = "gen_chatapi_create"
GEN_PROVIDER_POLL = "gen_provider_poll" GEN_PROVIDER_POLL = "gen_provider_poll"
GEN_RESULT_DOWNLOAD = "gen_result_download" GEN_RESULT_DOWNLOAD = "gen_result_download"
GEN_VIDEO_UPSCALE_LOCAL = "gen_video_upscale_local"
GEN_VIDEO_UPSCALE_REMOTE = "gen_video_upscale_remote"
GEN_RECOVERY = "gen_recovery" GEN_RECOVERY = "gen_recovery"
GEN_PRIVATE_PORTRAIT = "gen_private_portrait" GEN_PRIVATE_PORTRAIT = "gen_private_portrait"
DEFAULT = "default" DEFAULT = "default"
@@ -16,6 +18,12 @@ class CeleryTaskName(str, Enum):
DOWNLOAD_GENERATION_RESULT = "generation.download_generation_result_task" DOWNLOAD_GENERATION_RESULT = "generation.download_generation_result_task"
RECOVER_DOWNLOAD = "generation.recover_download_tasks_once" RECOVER_DOWNLOAD = "generation.recover_download_tasks_once"
RECOVER_GENERATION = "generation.recover_generation_tasks_once" RECOVER_GENERATION = "generation.recover_generation_tasks_once"
VIDEO_UPSCALE_EXECUTE_LOCAL = "video_upscale.execute_local"
VIDEO_UPSCALE_SUBMIT_REMOTE = "video_upscale.submit_remote"
VIDEO_UPSCALE_POLL_REMOTE = "video_upscale.poll_remote"
VIDEO_UPSCALE_DOWNLOAD_REMOTE_RESULT = "video_upscale.download_remote_result"
VIDEO_UPSCALE_FINALIZE = "video_upscale.finalize"
VIDEO_UPSCALE_RECOVER = "video_upscale.recover_once"
DISPATCH_DUE_POLL = "generation.dispatch_due_poll_tasks" DISPATCH_DUE_POLL = "generation.dispatch_due_poll_tasks"
STARTUP_RECOVERY = "recovery.startup_recovery_once" STARTUP_RECOVERY = "recovery.startup_recovery_once"
MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once" MODULE_ASYNC_RECOVERY = "module_async.recover_module_async_tasks_once"
@@ -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"
@@ -40,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"
@@ -108,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"
@@ -149,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 = {
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from enum import Enum
VIDEO_UPSCALE_CONFIG_KEY = "video_upscale_config"
VIDEO_UPSCALE_CONFIG_DESCRIPTION = "视频生成超分全局配置"
VIDEO_UPSCALE_CONFIG_VERSION = 1
VIDEO_UPSCALE_SOURCE_RETAINED_MARKER = "retained_by_snapshot_config"
class VideoUpscaleProcessorKey(str, Enum):
LOCAL_FFMPEG_CROP_V1 = "local_ffmpeg_crop_v1"
VOLC_LARGE_MODEL_V1 = "volc_large_model_v1"
VOLC_STANDARD_V1 = "volc_standard_v1"
VOLC_PROFESSIONAL_V1 = "volc_professional_v1"
class VideoUpscaleTaskStatus(str, Enum):
PENDING = "pending"
PROCESSING = "processing"
RETRY_WAITING = "retry_waiting"
COMPLETED = "completed"
FAILED = "failed"
class VideoUpscaleStage(str, Enum):
QUEUED = "upscale_queued"
SOURCE_READY = "upscale_source_ready"
LOCAL_PROCESSING = "upscale_local_processing"
REMOTE_SUBMITTING = "upscale_remote_submitting"
REMOTE_POLLING = "upscale_remote_polling"
RESULT_READY = "upscale_result_ready"
RESULT_DOWNLOADING = "upscale_result_downloading"
VALIDATING = "upscale_validating"
GENERATING_COVER = "upscale_generating_cover"
FINALIZING = "upscale_finalizing"
RETRY_WAITING = "upscale_retry_waiting"
COMPLETED = "upscale_completed"
FAILED = "upscale_failed"
class VideoUpscaleInputSourceType(str, Enum):
PROVIDER_REMOTE = "provider_remote"
LOCAL_SIGNED = "local_signed"
class VideoUpscaleProbeStatus(str, Enum):
NOT_CHECKED = "not_checked"
SUCCESS = "success"
FAILED = "failed"
EXPIRED = "expired"
UNPARSABLE = "unparsable"
VIDEO_UPSCALE_RESOLUTIONS = ("480p", "720p", "1080p", "2K", "4K")
VIDEO_UPSCALE_RESOLUTION_SHORT_EDGE = {
"480p": 480,
"720p": 720,
"1080p": 1080,
"2K": 1440,
"4K": 2160,
}
VIDEO_UPSCALE_RESOLUTION_RANK = {
resolution: index for index, resolution in enumerate(VIDEO_UPSCALE_RESOLUTIONS, start=1)
}
def normalize_video_upscale_resolution(value: str | None) -> str:
text = str(value or "").strip()
normalized = text.lower()
aliases = {
"480p": "480p",
"720p": "720p",
"1080p": "1080p",
"2k": "2K",
"4k": "4K",
}
return aliases.get(normalized, text)
def video_upscale_short_edge_pixels(value: str | None) -> int:
normalized = normalize_video_upscale_resolution(value)
try:
return VIDEO_UPSCALE_RESOLUTION_SHORT_EDGE[normalized]
except KeyError as exc:
supported = "".join(VIDEO_UPSCALE_RESOLUTIONS)
raise ValueError(f"不支持的视频超分分辨率: {value},仅支持 {supported}") from exc
LOCAL_PROCESSOR_KEYS = {VideoUpscaleProcessorKey.LOCAL_FFMPEG_CROP_V1.value}
REMOTE_PROCESSOR_KEYS = {
VideoUpscaleProcessorKey.VOLC_LARGE_MODEL_V1.value,
VideoUpscaleProcessorKey.VOLC_STANDARD_V1.value,
VideoUpscaleProcessorKey.VOLC_PROFESSIONAL_V1.value,
}
ALL_PROCESSOR_KEYS = LOCAL_PROCESSOR_KEYS | REMOTE_PROCESSOR_KEYS
+14
View File
@@ -293,6 +293,20 @@ async def _seed_data():
) )
) )
# 文字模型媒体使用 base64 开关
existing_media_format = await db.execute(
select(SystemConfig).where(SystemConfig.key == "llm_media_as_base64").limit(1)
)
if not existing_media_format.scalar_one_or_none():
db.add(
SystemConfig(
id=generate_id(),
key="llm_media_as_base64",
value="true",
description="文字模型请求时图片/视频使用 base64 编码(而非 URL 链接)",
)
)
# Seed credit ratios - model_config_id is kept as a compatible field name, # Seed credit ratios - model_config_id is kept as a compatible field name,
# but now stores the actual engine id: # but now stores the actual engine id:
# - gen_type=video -> video_engines.id # - gen_type=video -> video_engines.id
+2 -1
View File
@@ -21,6 +21,7 @@ from app.models.operation_log import OperationLog
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
from app.models.chat_generation_task_event import ChatGenerationTaskEvent from app.models.chat_generation_task_event import ChatGenerationTaskEvent
from app.models.chat_provider_call_log import ChatProviderCallLog from app.models.chat_provider_call_log import ChatProviderCallLog
from app.models.video_upscale_task import VideoUpscaleTask
from app.models.generated_resource import GeneratedResource from app.models.generated_resource import GeneratedResource
from app.models.upload_resource import UploadResource from app.models.upload_resource import UploadResource
from app.models.user_resource_month_stat import UserResourceMonthStat from app.models.user_resource_month_stat import UserResourceMonthStat
@@ -44,7 +45,7 @@ __all__ = [
"ModelConfig", "SystemConfig", "Notification", "PaymentOrder", "ModelConfig", "SystemConfig", "Notification", "PaymentOrder",
"TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio", "TokenUsage", "IndustryConfig", "VideoEngine", "CreditRatio",
"MenuConfig", "RechargePackage", "OperationLog", "ContactRequest", "MenuConfig", "RechargePackage", "OperationLog", "ContactRequest",
"ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "ChatGenerationTask", "ChatGenerationTaskEvent", "ChatProviderCallLog", "VideoUpscaleTask",
"GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat", "GeneratedResource", "UploadResource", "UserResourceMonthStat", "UserResourceTotalStat",
"UserResourceCapacityConfig", "UserResourceCapacityConfig",
"ModuleGenerationProject", "ModuleGenerationStep", "ModuleGenerationProject", "ModuleGenerationStep",
@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import CheckConstraint, 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
@@ -76,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)
@@ -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)
@@ -0,0 +1,85 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import BigInteger, CheckConstraint, DateTime, Float, ForeignKey, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class VideoUpscaleTask(Base, TimestampMixin):
__tablename__ = "video_upscale_tasks"
__table_args__ = (
CheckConstraint(
"(chat_generation_task_id IS NOT NULL AND generation_record_id IS NULL) OR "
"(chat_generation_task_id IS NULL AND generation_record_id IS NOT NULL)",
name="ck_video_upscale_tasks_exactly_one_owner",
),
Index("uq_video_upscale_tasks_chat_task", "chat_generation_task_id", unique=True),
Index("uq_video_upscale_tasks_generation_record", "generation_record_id", unique=True),
Index("idx_video_upscale_tasks_provider_task_id", "provider_task_id"),
Index("idx_video_upscale_tasks_status_next_retry", "status", "next_retry_at"),
Index("idx_video_upscale_tasks_status_lease", "status", "lease_until"),
)
id: Mapped[str] = mapped_column(String(32), primary_key=True)
chat_generation_task_id: Mapped[str | None] = mapped_column(
String(32),
ForeignKey("chat_generation_tasks.id", ondelete="CASCADE"),
nullable=True,
)
generation_record_id: Mapped[str | None] = mapped_column(
String(32),
ForeignKey("generation_records.id", ondelete="CASCADE"),
nullable=True,
)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending", server_default="pending")
stage: Mapped[str] = mapped_column(String(48), nullable=False, default="upscale_queued", server_default="upscale_queued")
processor_key: Mapped[str] = mapped_column(String(64), nullable=False)
attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
failure_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
manual_retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
next_retry_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
source_local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
source_file_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
source_width: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
source_duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
source_deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
source_delete_error: Mapped[str | None] = mapped_column(Text, nullable=True)
source_remote_url: Mapped[str | None] = mapped_column(Text, nullable=True)
source_remote_url_signed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
source_remote_url_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
source_remote_url_last_probe_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
source_remote_url_probe_status: Mapped[str | None] = mapped_column(String(32), nullable=True)
input_source_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
input_source_fallback_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
target_width: Mapped[int] = mapped_column(Integer, nullable=False)
target_height: Mapped[int] = mapped_column(Integer, nullable=False)
effective_target_width: Mapped[int | None] = mapped_column(Integer, nullable=True)
effective_target_height: Mapped[int | None] = mapped_column(Integer, nullable=True)
provider_task_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
provider_request_json: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_response_json: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_output_url: Mapped[str | None] = mapped_column(Text, nullable=True)
provider_output_url_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
provider_submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
final_local_path: Mapped[str | None] = mapped_column(Text, nullable=True)
final_resource_url: Mapped[str | None] = mapped_column(Text, nullable=True)
final_file_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
celery_task_id: Mapped[str | None] = mapped_column(String(160), nullable=True)
lease_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
lease_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
failed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+6
View File
@@ -29,6 +29,12 @@ class ModelConfigOut(ModelConfigCreate):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class SystemConfigCreate(BaseModel):
key: str
value: str
description: str | None = None
class SystemConfigUpdate(BaseModel): class SystemConfigUpdate(BaseModel):
value: str | int | float value: str | int | float
+2
View File
@@ -50,6 +50,8 @@ class GenerationRecordOut(BaseModel):
image_proportion: str | None = None image_proportion: str | None = None
image_px: str | None = None image_px: str | None = None
status: str status: str
pipeline_stage: str | None = None
video_upscale_enabled: bool = False
video_url: str | None = None video_url: str | None = None
video_cover_url: str | None = None video_cover_url: str | None = None
image_url: str | None = None image_url: str | None = None
@@ -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
@@ -40,6 +40,7 @@ from app.services.generation.billing_service import OWNER_CHAT_GENERATION_TASK,
from app.services.operation_log_service import log_operation_event 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.private_portrait.reference_resolver import resolve_private_portrait_references
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.utils.id_gen import generate_id from app.utils.id_gen import generate_id
@@ -331,7 +332,15 @@ async def create_generation_task_group(
raise HTTPException(status_code=400, detail=f"视频时长不能超过 {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) 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 = 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 video_snapshot["generation_count"] = generation_count
snapshot_json = _json(video_snapshot) or "{}" snapshot_json = _json(video_snapshot) or "{}"
deadline_at = now + timedelta(hours=settings.CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS) deadline_at = now + timedelta(hours=settings.CHATAPI_ASYNC_VIDEO_FINAL_DEADLINE_HOURS)
@@ -370,6 +379,9 @@ async def create_generation_task_group(
duration=duration, duration=duration,
aspect_ratio=ratio, aspect_ratio=ratio,
resolution=resolution, 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_size=req.image_size or IMAGE_DEFAULT_SIZE,
image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION, image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION,
image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX, image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX,
@@ -394,6 +406,9 @@ async def create_generation_task_group(
duration=duration, duration=duration,
aspect_ratio=ratio, aspect_ratio=ratio,
resolution=resolution, 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_size=req.image_size or IMAGE_DEFAULT_SIZE,
image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION, image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION,
image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX, image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX,
@@ -438,6 +453,9 @@ async def create_generation_task_group(
duration=duration, duration=duration,
aspect_ratio=ratio, aspect_ratio=ratio,
resolution=resolution, 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_size=req.image_size or IMAGE_DEFAULT_SIZE,
image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION, image_proportion=req.image_proportion or IMAGE_DEFAULT_PROPORTION,
image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX, image_px=normalize_px(req.image_px) or IMAGE_DEFAULT_PX,
@@ -476,6 +494,8 @@ async def create_generation_task_group(
"generation_count": generation_count, "generation_count": generation_count,
"child_task_ids": child_ids, "child_task_ids": child_ids,
"enqueue_task_ids": enqueue_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( return GenerationTaskCreateResult(
@@ -15,6 +15,7 @@ from app.enums.generation_task import (
) )
from app.models.chat_generation_task import ChatGenerationTask from app.models.chat_generation_task import ChatGenerationTask
from app.services.operation_log_service import log_operation_event 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 ( from app.services.resource_accounting_service import (
SOURCE_MODEL_CHAT_TASK, SOURCE_MODEL_CHAT_TASK,
soft_delete_resources_by_source, soft_delete_resources_by_source,
@@ -31,6 +32,12 @@ ACTIVE_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,
} }
@@ -46,6 +53,8 @@ def get_display_status(task: ChatGenerationTask) -> str:
return "deleted" return "deleted"
if task.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value: if task.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value:
return "download_failed" return "download_failed"
if task.pipeline_stage == ChatGenerationPipelineStage.UPSCALE_FAILED.value:
return "failed"
return task.status or ChatGenerationTaskStatus.PENDING.value return task.status or ChatGenerationTaskStatus.PENDING.value
@@ -115,6 +124,7 @@ def _generation_result_status(task: ChatGenerationTask) -> str:
if task.status == ChatGenerationTaskStatus.FAILED.value or (task.pipeline_stage or "") in { if task.status == ChatGenerationTaskStatus.FAILED.value or (task.pipeline_stage or "") in {
ChatGenerationPipelineStage.FAILED.value, ChatGenerationPipelineStage.FAILED.value,
ChatGenerationPipelineStage.TIMEOUT.value, ChatGenerationPipelineStage.TIMEOUT.value,
ChatGenerationPipelineStage.UPSCALE_FAILED.value,
}: }:
return "failed" return "failed"
if is_task_active(task): if is_task_active(task):
@@ -177,6 +187,7 @@ async def aggregate_main_task_status(
ChatGenerationPipelineStage.FAILED.value, ChatGenerationPipelineStage.FAILED.value,
ChatGenerationPipelineStage.TIMEOUT.value, ChatGenerationPipelineStage.TIMEOUT.value,
ChatGenerationPipelineStage.DOWNLOAD_FAILED.value, ChatGenerationPipelineStage.DOWNLOAD_FAILED.value,
ChatGenerationPipelineStage.UPSCALE_FAILED.value,
} }
) )
] ]
@@ -191,11 +202,12 @@ async def aggregate_main_task_status(
main.error_message = _build_summary(children) main.error_message = _build_summary(children)
elif failed_children: elif failed_children:
main.status = ChatGenerationTaskStatus.FAILED.value main.status = ChatGenerationTaskStatus.FAILED.value
main.pipeline_stage = ( if any(child.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value for child in failed_children):
ChatGenerationPipelineStage.DOWNLOAD_FAILED.value main.pipeline_stage = ChatGenerationPipelineStage.DOWNLOAD_FAILED.value
if any(child.pipeline_stage == ChatGenerationPipelineStage.DOWNLOAD_FAILED.value for child in failed_children) elif any(child.pipeline_stage == ChatGenerationPipelineStage.UPSCALE_FAILED.value for child in failed_children):
else ChatGenerationPipelineStage.FAILED.value main.pipeline_stage = ChatGenerationPipelineStage.UPSCALE_FAILED.value
) else:
main.pipeline_stage = ChatGenerationPipelineStage.FAILED.value
main.generated_at = max( main.generated_at = max(
(child.generated_at for child in completed_children if child.generated_at), (child.generated_at for child in completed_children if child.generated_at),
default=datetime.now(timezone.utc), default=datetime.now(timezone.utc),
@@ -279,6 +291,7 @@ async def soft_delete_child_tasks_batch(
running_ids = [child.id for child in active_children if is_task_active(child)] running_ids = [child.id for child in active_children if is_task_active(child)]
if running_ids: if running_ids:
raise HTTPException(status_code=400, detail=f"仍有 {len(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: if require_completed:
invalid_ids = [ invalid_ids = [
child.id for child in active_children child.id for child in active_children
@@ -381,6 +394,7 @@ async def soft_delete_top_level_task_group(
if task.generation_mode == GenerationMode.CHATAPI_ASYNC.value: if task.generation_mode == GenerationMode.CHATAPI_ASYNC.value:
if is_task_active(task): if is_task_active(task):
raise HTTPException(status_code=400, detail="当前任务正在生成中,暂不能删除") 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( freed_size = await soft_delete_resources_by_source(
db, db,
source_model=SOURCE_MODEL_CHAT_TASK, source_model=SOURCE_MODEL_CHAT_TASK,
@@ -399,6 +413,7 @@ async def soft_delete_top_level_task_group(
active_children = [child for child in children if child.deleted_at is None] active_children = [child for child in children if child.deleted_at is None]
child_ids = [child.id for child in active_children] 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( freed_size = await soft_delete_resources_by_source(
db, db,
source_model=SOURCE_MODEL_CHAT_TASK, source_model=SOURCE_MODEL_CHAT_TASK,
@@ -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")
@@ -28,6 +28,7 @@ from app.services.module_generation_flow_base_service import is_active_chat_gene
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_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_SHOT_SEGMENT, SOURCE_MODEL_SHOT_SEGMENT,
@@ -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
@@ -251,6 +256,8 @@ async def _delete_chat_tasks(
already_deleted_ids = [str(task.id) for task in tasks if task.deleted_at is not None] 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) _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
@@ -359,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,
@@ -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,
@@ -412,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))
@@ -27,6 +27,7 @@ from app.services.generation.ai.engine_service import (
) )
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
@@ -164,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,
@@ -184,6 +191,8 @@ async def create_chat_generation_task_for_module(
) )
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["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,
@@ -193,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,
@@ -1536,9 +1536,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)
+60 -49
View File
@@ -248,7 +248,63 @@ async def _call_openai_compatible(
if ref_type == "video": if ref_type == "video":
video_urls.append(ref_url) video_urls.append(ref_url)
def _build_file_url_or_data_uri(file_url: str, fallback_mime: str) -> str: async def _build_multimodal_content(
user_content: str,
image_urls: list[str],
video_urls: list[str],
) -> tuple[dict, dict | None]:
"""构建多模态 user_message。返回 (actual_message, log_message)。"""
from app.utils.media import media_to_base64
content_parts = [{"type": "text", "text": user_content}]
from app.utils.media import get_llm_media_as_base64
as_base64 = await get_llm_media_as_base64(db)
for img in image_urls:
if as_base64:
url = await media_to_base64(img, "image/png")
else:
url = _file_url_or_data_uri(img, "image/png")
content_parts.append({
"type": "image_url",
"image_url": {"url": url},
})
for video in video_urls:
if as_base64:
url = await media_to_base64(video, "video/mp4")
else:
url = _file_url_or_data_uri(video, "video/mp4")
content_parts.append({
"type": "video_url",
"video_url": {"url": url},
})
user_message = {
"role": "user",
"content": content_parts,
}
# Log-friendly version: keep original paths instead of base64
log_content_parts = [{"type": "text", "text": user_content}]
for img in image_urls:
log_content_parts.append({
"type": "image_url",
"image_url": {"url": img},
})
for video in video_urls:
log_content_parts.append({
"type": "video_url",
"video_url": {"url": video},
})
log_user_message = {
"role": "user",
"content": log_content_parts,
}
return user_message, log_user_message
def _file_url_or_data_uri(file_url: str, fallback_mime: str) -> str:
""" """
Convert local upload path to base64 data URI. Convert local upload path to base64 data URI.
Keep remote http/https/data URLs as-is. Keep remote http/https/data URLs as-is.
@@ -268,54 +324,9 @@ async def _call_openai_compatible(
return f"data:{mime};base64,{b64}" return f"data:{mime};base64,{b64}"
if image_urls or video_urls: if image_urls or video_urls:
content_parts = [{"type": "text", "text": user_content}] user_message, log_user_message = await _build_multimodal_content(
user_content, image_urls, video_urls
for img in image_urls: )
url = _build_file_url_or_data_uri(img, "image/png")
content_parts.append({
"type": "image_url",
"image_url": {
"url": url,
},
})
for video in video_urls:
url = _build_file_url_or_data_uri(video, "video/mp4")
content_parts.append({
"type": "video_url",
"video_url": {
"url": url,
},
})
user_message = {
"role": "user",
"content": content_parts,
}
# Log-friendly version: keep original paths instead of base64
log_content_parts = [{"type": "text", "text": user_content}]
for img in image_urls:
log_content_parts.append({
"type": "image_url",
"image_url": {
"url": img,
},
})
for video in video_urls:
log_content_parts.append({
"type": "video_url",
"video_url": {
"url": video,
},
})
log_user_message = {
"role": "user",
"content": log_content_parts,
}
else: else:
user_message = { user_message = {
"role": "user", "role": "user",
@@ -242,6 +242,12 @@ ACTIVE_CHAT_TASK_BLOCK_STAGES = {
ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value, ChatGenerationPipelineStage.DOWNLOAD_QUEUED.value,
ChatGenerationPipelineStage.DOWNLOADING.value, ChatGenerationPipelineStage.DOWNLOADING.value,
ChatGenerationPipelineStage.RETRY_WAITING.value, ChatGenerationPipelineStage.RETRY_WAITING.value,
ChatGenerationPipelineStage.UPSCALE_QUEUED.value,
ChatGenerationPipelineStage.UPSCALE_PROCESSING.value,
ChatGenerationPipelineStage.UPSCALE_POLLING.value,
ChatGenerationPipelineStage.UPSCALE_DOWNLOADING.value,
ChatGenerationPipelineStage.UPSCALE_FINALIZING.value,
ChatGenerationPipelineStage.UPSCALE_RETRY_WAITING.value,
} }
@@ -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,
+1 -1
View File
@@ -148,7 +148,7 @@ async def submit_video_task(
"content": content, "content": content,
"ratio": record.aspect_ratio, "ratio": record.aspect_ratio,
"duration": record.duration, "duration": record.duration,
"resolution": record.resolution, "resolution": getattr(record, "provider_generation_resolution", None) or record.resolution,
"generate_audio": True, "generate_audio": True,
"watermark": False, "watermark": False,
} }
+297 -144
View File
@@ -2,123 +2,267 @@ import asyncio
import json import json
import logging import logging
import os import os
from datetime import datetime from datetime import datetime, timezone
from urllib.parse import urlparse
from sqlalchemy import select from sqlalchemy import or_, select
from app.config import settings
from app.enums.generation_status import GenerationRecordPipelineStage
from app.models.base import async_session from app.models.base import async_session
from app.models.generation_record import GenerationRecord from app.models.generation_record import GenerationRecord
from app.services.video_gen import get_active_engine, poll_task_status, download_video, _log_video_response from app.services.generation.refund_service import mark_generation_record_failed_and_refund_once
from app.services.image_gen import get_active_image_engine, download_image from app.services.image_gen import download_image, get_active_image_engine
from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot from app.services.media_token_usage_snapshot_service import sync_generation_record_media_token_snapshot
from app.services.provider_limit import provider_limit
from app.services.resource_accounting_service import ( from app.services.resource_accounting_service import (
record_generation_record_generated_resource, record_generation_record_generated_resource,
safe_file_size, safe_file_size,
) )
from app.services.video_cover_service import create_video_cover_for_local_video from app.services.video_cover_service import create_video_cover_for_local_video
from app.config import settings from app.services.video_gen import _log_video_response, download_video, get_active_engine, poll_task_status
from app.services.generation.refund_service import mark_generation_record_failed_and_refund_once from app.services.video_upscale.media_service import build_part_mp4_path, probe_video, safe_remove
logger = logging.getLogger("videogen") logger = logging.getLogger("videogen")
POLL_INTERVAL = 30 # seconds between polls POLL_INTERVAL = 30
MAX_POLLS = 60 # max 30 minutes total MAX_POLLS = 60
_PROVIDER_RECOVERABLE_STAGES = {
None,
"",
GenerationRecordPipelineStage.CREATING_PROVIDER_TASK.value,
GenerationRecordPipelineStage.WAITING_REMOTE.value,
GenerationRecordPipelineStage.POLLING.value,
GenerationRecordPipelineStage.RESULT_READY.value,
GenerationRecordPipelineStage.DOWNLOADING.value,
}
def _is_provider_stage(record: GenerationRecord) -> bool:
return (record.pipeline_stage or "") in _PROVIDER_RECOVERABLE_STAGES
def _source_date_dir(record: GenerationRecord) -> str:
created = record.created_at
if created is None:
created = datetime.now(timezone.utc)
return created.strftime("%Y/%m/%d")
def _normalize_image_extension(output_format: str | None, remote_url: str | None = None) -> str:
value = str(output_format or "").strip().lower()
if value in {"jpg", "jpeg"}:
return "jpg"
if value == "png":
return "png"
if value == "webp":
return "webp"
if remote_url:
try:
path = urlparse(remote_url).path or ""
except Exception:
path = str(remote_url)
suffix = os.path.splitext(path)[1].lower().lstrip(".")
if suffix in {"jpg", "jpeg"}:
return "jpg"
if suffix == "png":
return "png"
if suffix == "webp":
return "webp"
return "jpg"
async def _download_generation_record_upscale_source(
record: GenerationRecord,
remote_url: str,
) -> tuple[str, int]:
date_dir = _source_date_dir(record)
dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, "_upscale_source", date_dir)
os.makedirs(dest_dir, exist_ok=True)
final_path = os.path.join(dest_dir, f"{record.id}.source.mp4")
if os.path.isfile(final_path) and os.path.getsize(final_path) > 0:
await probe_video(final_path)
return final_path, safe_file_size(final_path)
part_path = build_part_mp4_path(final_path)
try:
async with provider_limit("result_download", settings.RESULT_DOWNLOAD_MAX_CONCURRENCY):
await download_video(remote_url, part_path)
if not os.path.isfile(part_path) or os.path.getsize(part_path) <= 0:
raise RuntimeError("超分源视频下载完成但临时文件为空")
await probe_video(part_path)
os.replace(part_path, final_path)
return final_path, safe_file_size(final_path)
except Exception:
safe_remove(part_path)
raise
async def handle_generation_record_video_succeeded(
db,
record: GenerationRecord,
*,
remote_url: str,
provider_response: dict | None,
video_tokens: int = 0,
) -> bool:
"""处理 GenerationRecord 原视频生成成功。
返回 True 表示已进入超分队列False 表示按原流程直接完成
"""
record.video_tokens_used = int(video_tokens or 0)
await sync_generation_record_media_token_snapshot(db, record, provider_response=provider_response or {})
if bool(record.video_upscale_enabled_snapshot) and record.video_upscale_snapshot_json:
record.pipeline_stage = GenerationRecordPipelineStage.DOWNLOADING.value
await db.flush()
source_path, source_size = await _download_generation_record_upscale_source(record, remote_url)
from app.services.video_upscale.task_service import enqueue_upscale_task, prepare_video_upscale_task
upscale = await prepare_video_upscale_task(
db,
generation_record=record,
source_local_path=source_path,
source_file_size_bytes=source_size,
source_remote_url=remote_url,
)
# enqueue_upscale_task 会先提交数据库,再投递 Celery;投递失败由 gen_recovery 补投。
await enqueue_upscale_task(db, upscale=upscale, reason="generation_record_source_ready")
logger.info("GenerationRecord 已进入视频超分队列: record_id=%s upscale_task_id=%s", record.id, upscale.id)
return True
storage_path = None
file_size_bytes = 0
if settings.STORAGE_TYPE == "local" and remote_url:
try:
date_dir = _source_date_dir(record)
dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, date_dir)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, f"{record.id}.mp4")
await download_video(remote_url, dest)
record.video_url = f"/generate/videos/{date_dir}/{record.id}.mp4"
cover_url, _cover_storage_path = create_video_cover_for_local_video(
record_id=record.id,
video_path=dest,
date_dir=date_dir,
log_prefix=f"GenerationRecord视频封面生成 record_id={record.id}",
)
record.video_cover_url = cover_url
storage_path = dest
file_size_bytes = safe_file_size(dest)
except Exception as exc:
logger.warning("GenerationRecord 最终视频本地保存失败,回退远程地址: record_id=%s error=%s", record.id, exc)
record.video_url = remote_url
else:
record.video_url = remote_url
record.status = "completed"
record.pipeline_stage = GenerationRecordPipelineStage.DONE.value
record.generated_at = datetime.now(timezone.utc)
record.error_message = None
if record.video_url:
await record_generation_record_generated_resource(
db,
record,
resource_url=record.video_url,
storage_path=storage_path,
file_size_bytes=file_size_bytes,
remote_url=remote_url,
generated_at=record.generated_at,
)
await db.commit()
return False
class TaskQueue: class TaskQueue:
def __init__(self): def __init__(self):
self.queue: asyncio.Queue[str] = asyncio.Queue() self.queue: asyncio.Queue[str] = asyncio.Queue()
self.running = False self.running = False
self._active: dict[str, int] = {} # record_id -> poll count self._active: dict[str, int] = {}
async def enqueue(self, record_id: str): async def enqueue(self, record_id: str):
"""Add a record to the polling queue."""
await self.queue.put(record_id) await self.queue.put(record_id)
async def recover(self): async def recover(self):
"""Recover in-progress tasks from DB on startup."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(GenerationRecord).where( select(GenerationRecord).where(
GenerationRecord.status == "generating", GenerationRecord.status == "generating",
GenerationRecord.seedance_task_id.isnot(None), GenerationRecord.seedance_task_id.isnot(None),
GenerationRecord.deleted_at.is_(None), GenerationRecord.deleted_at.is_(None),
or_(
GenerationRecord.pipeline_stage.is_(None),
GenerationRecord.pipeline_stage.in_(
[stage for stage in _PROVIDER_RECOVERABLE_STAGES if stage]
),
),
) )
) )
records = result.scalars().all() records = result.scalars().all()
for record in records: for record in records:
await self.queue.put(record.id) await self.queue.put(record.id)
logger.info(f"Recovered task: {record.id} (seedance: {record.seedance_task_id})") logger.info("Recovered task: %s (seedance: %s stage=%s)", record.id, record.seedance_task_id, record.pipeline_stage)
async def run(self): async def run(self):
"""Main polling loop."""
self.running = True self.running = True
logger.info("Video queue started") logger.info("Video queue started")
while self.running: while self.running:
try: try:
record_id = await asyncio.wait_for(self.queue.get(), timeout=5.0) record_id = await asyncio.wait_for(self.queue.get(), timeout=5.0)
except asyncio.TimeoutError: except asyncio.TimeoutError:
continue continue
try: try:
await self._process(record_id) await self._process(record_id)
except Exception as e: except Exception as exc:
logger.error(f"Error processing {record_id}: {e}") logger.exception("Error processing %s: %s", record_id, exc)
finally: finally:
self.queue.task_done() self.queue.task_done()
logger.info("Video queue stopped") logger.info("Video queue stopped")
async def _process(self, record_id: str): async def _process(self, record_id: str):
"""Process a single record: poll status and update DB."""
async with async_session() as db: async with async_session() as db:
result = await db.execute( result = await db.execute(
select(GenerationRecord).where( select(GenerationRecord).where(
GenerationRecord.id == record_id, GenerationRecord.id == record_id,
GenerationRecord.deleted_at.is_(None), GenerationRecord.deleted_at.is_(None),
) ).with_for_update().limit(1)
.with_for_update()
.limit(1)
) )
record = result.scalar_one_or_none() record = result.scalar_one_or_none()
if not record or record.status != "generating": if not record or record.status != "generating":
return return
if record.gen_type == "video": if record.gen_type == "video":
if not _is_provider_stage(record):
return
await self._process_video(db, record) await self._process_video(db, record)
else: else:
await self._process_image(db, record) await self._process_image(db, record)
async def _process_video(self, db, record): async def _process_video(self, db, record: GenerationRecord):
"""Process video generation task."""
record_id = record.id record_id = record.id
if not record.seedance_task_id: if not record.seedance_task_id:
await mark_generation_record_failed_and_refund_once( record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
db, await mark_generation_record_failed_and_refund_once(db, record=record, error_message="缺少外部任务ID")
record=record,
error_message="缺少外部任务ID",
)
await db.commit() await db.commit()
return return
record.pipeline_stage = GenerationRecordPipelineStage.POLLING.value
try: try:
engine = await get_active_engine(db) engine = await get_active_engine(db)
poll_result = await poll_task_status(engine, record.seedance_task_id) poll_result = await poll_task_status(engine, record.seedance_task_id)
except Exception as e: except Exception as exc:
logger.error(f"Poll error for {record_id}: {e}") logger.error("Poll error for %s: %s", record_id, exc)
count = self._active.get(record_id, 0) + 1 count = self._active.get(record_id, 0) + 1
self._active[record_id] = count self._active[record_id] = count
if count >= MAX_POLLS: if count >= MAX_POLLS:
await mark_generation_record_failed_and_refund_once( record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
db, await mark_generation_record_failed_and_refund_once(db, record=record, error_message=f"轮询超时: {exc}")
record=record,
error_message=f"轮询超时: {e}",
)
await db.commit() await db.commit()
del self._active[record_id] self._active.pop(record_id, None)
else: else:
await db.commit()
await asyncio.sleep(POLL_INTERVAL) await asyncio.sleep(POLL_INTERVAL)
await self.queue.put(record_id) await self.queue.put(record_id)
return return
@@ -128,54 +272,45 @@ class TaskQueue:
resp_data = json.loads(poll_result.get("response_data", "{}")) resp_data = json.loads(poll_result.get("response_data", "{}"))
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
resp_data = {} resp_data = {}
_log_video_response(record_id, resp_data, poll_result.get("error")) _log_video_response(record_id, resp_data, poll_result.get("error"))
if status == "succeeded": if status == "succeeded":
file_url = poll_result.get("video_url", "") file_url = str(poll_result.get("video_url") or "").strip()
storage_path = None if not file_url:
file_size_bytes = 0 record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
if settings.STORAGE_TYPE == "local" and file_url: await mark_generation_record_failed_and_refund_once(db, record=record, error_message="供应商成功但未返回视频地址")
try: await db.commit()
date_dir = datetime.now().strftime("%Y/%m/%d") return
dest_dir = os.path.join(settings.STORAGE_LOCAL_PATH, date_dir) record.pipeline_stage = GenerationRecordPipelineStage.RESULT_READY.value
os.makedirs(dest_dir, exist_ok=True) await db.flush()
dest = os.path.join(dest_dir, f"{record_id}.mp4") try:
await download_video(file_url, dest) await handle_generation_record_video_succeeded(
record.video_url = f"/generate/videos/{date_dir}/{record_id}.mp4"
cover_url, _cover_storage_path = create_video_cover_for_local_video(
record_id=record_id,
video_path=dest,
date_dir=date_dir,
log_prefix=f"GenerationRecord视频封面生成 record_id={record_id}",
)
record.video_cover_url = cover_url
storage_path = dest
file_size_bytes = safe_file_size(dest)
except Exception as e:
logger.warning(f"Download failed, using remote URL: {e}")
record.video_url = file_url
else:
record.video_url = file_url
record.video_tokens_used = poll_result.get("video_tokens", 0)
await sync_generation_record_media_token_snapshot(db, record, provider_response=resp_data)
record.status = "completed"
record.generated_at = datetime.now()
if record.video_url:
await record_generation_record_generated_resource(
db, db,
record, record,
resource_url=record.video_url,
storage_path=storage_path,
file_size_bytes=file_size_bytes,
remote_url=file_url, remote_url=file_url,
generated_at=record.generated_at, provider_response=resp_data,
video_tokens=poll_result.get("video_tokens", 0),
) )
except Exception as exc:
await db.rollback()
result = await db.execute(
select(GenerationRecord).where(GenerationRecord.id == record_id).with_for_update().limit(1)
)
failed_record = result.scalar_one_or_none()
if failed_record:
failed_record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once(
db,
record=failed_record,
error_message=f"视频结果下载失败: {exc}",
)
await db.commit()
logger.exception("GenerationRecord 视频成功结果处理失败: %s", record_id)
self._active.pop(record_id, None) self._active.pop(record_id, None)
await db.commit() return
logger.info(f"Video task completed: {record_id}")
elif status == "failed": if status == "failed":
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once( await mark_generation_record_failed_and_refund_once(
db, db,
record=record, record=record,
@@ -183,29 +318,26 @@ class TaskQueue:
) )
self._active.pop(record_id, None) self._active.pop(record_id, None)
await db.commit() await db.commit()
logger.info(f"Video task failed: {record_id}") logger.info("Video task failed: %s", record_id)
return
count = self._active.get(record_id, 0) + 1
self._active[record_id] = count
if count >= MAX_POLLS:
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once(db, record=record, error_message="视频生成超时")
self._active.pop(record_id, None)
await db.commit()
logger.info("Video task timed out: %s", record_id)
else: else:
count = self._active.get(record_id, 0) + 1 await db.commit()
self._active[record_id] = count await asyncio.sleep(POLL_INTERVAL)
if count >= MAX_POLLS: await self.queue.put(record_id)
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message="视频生成超时",
)
self._active.pop(record_id, None)
await db.commit()
logger.info(f"Video task timed out: {record_id}")
else:
await db.commit()
await asyncio.sleep(POLL_INTERVAL)
await self.queue.put(record_id)
async def _process_image(self, db, record): async def _process_image(self, db, record: GenerationRecord):
"""Process image generation task - calls API directly.""" """Process image generation task - calls API directly."""
record_id = record.id record_id = record.id
from app.services.image_gen import submit_image_task, _log_image_response from app.services.image_gen import submit_image_task
try: try:
engine = await get_active_image_engine(db) engine = await get_active_image_engine(db)
@@ -217,60 +349,81 @@ class TaskQueue:
include_media_references=False, include_media_references=False,
) )
if poll_result["error"] == "": if not isinstance(poll_result, dict):
remote_url = poll_result.get("image_url") raise RuntimeError("图片供应商返回结构异常")
storage_path = None
file_size_bytes = 0
if settings.STORAGE_TYPE == "local" and remote_url:
try:
date_dir = datetime.now().strftime("%Y/%m/%d")
dest_dir = os.path.join(settings.STORAGE_IMAGE_LOCAL_PATH, date_dir)
os.makedirs(dest_dir, exist_ok=True)
dest = os.path.join(dest_dir, f"{record_id}.png")
await download_image(remote_url, dest)
record.image_url = f"/generate/images/{date_dir}/{record_id}.png"
storage_path = dest
file_size_bytes = safe_file_size(dest)
except Exception as e:
logger.warning(f"Download failed, using remote URL: {e}")
record.image_url = remote_url
else:
record.image_url = remote_url
record.image_tokens_used = poll_result.get("image_tokens", 0)
await sync_generation_record_media_token_snapshot(db, record, provider_response=poll_result)
record.status = "completed"
record.generated_at = datetime.now()
if record.image_url:
await record_generation_record_generated_resource(
db,
record,
resource_url=record.image_url,
storage_path=storage_path,
file_size_bytes=file_size_bytes,
remote_url=remote_url,
generated_at=record.generated_at,
)
await db.commit()
logger.info(f"Image task completed: {record_id}")
else:
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message=poll_result.get("error", "图片生成失败"),
)
await db.commit()
logger.info(f"Image task failed: {record_id}")
_log_image_response(record_id, poll_result)
except Exception as e: items = poll_result.get("items") or []
if not isinstance(items, list):
raise RuntimeError("图片供应商返回结果列表异常")
if not items:
raise RuntimeError("图片供应商未返回图片结果")
if len(items) != 1:
raise RuntimeError(f"图片供应商单图返回数量异常,期望 1,实际 {len(items)}")
item = items[0] or {}
if not isinstance(item, dict):
raise RuntimeError("图片供应商返回单项结果结构异常")
item_error = item.get("error_message") or item.get("error_code")
if item_error:
raise RuntimeError(str(item_error))
remote_url = str(item.get("remote_result_url") or "").strip()
if not remote_url:
raise RuntimeError("图片供应商成功响应但没有图片地址")
storage_path = None
file_size_bytes = 0
if settings.STORAGE_TYPE == "local":
try:
date_dir = _source_date_dir(record)
dest_dir = os.path.join(settings.STORAGE_IMAGE_LOCAL_PATH, date_dir)
os.makedirs(dest_dir, exist_ok=True)
extension = _normalize_image_extension(item.get("output_format"), remote_url)
dest = os.path.join(dest_dir, f"{record_id}.{extension}")
await download_image(remote_url, dest)
record.image_url = f"/generate/images/{date_dir}/{record_id}.{extension}"
storage_path = dest
file_size_bytes = safe_file_size(dest)
except Exception as exc:
logger.warning("GenerationRecord 图片本地保存失败,回退远程地址: record_id=%s error=%s", record_id, exc)
record.image_url = remote_url
else:
record.image_url = remote_url
record.image_tokens_used = int(poll_result.get("image_tokens", 0) or 0)
provider_response = poll_result.get("response_data") or {}
await sync_generation_record_media_token_snapshot(
db,
record,
provider_response=provider_response if isinstance(provider_response, dict) else {},
)
record.status = "completed"
record.pipeline_stage = GenerationRecordPipelineStage.DONE.value
record.generated_at = datetime.now(timezone.utc)
record.error_message = None
if record.image_url:
await record_generation_record_generated_resource(
db,
record,
resource_url=record.image_url,
storage_path=storage_path,
file_size_bytes=file_size_bytes,
remote_url=remote_url,
generated_at=record.generated_at,
)
await db.commit()
logger.info("Image task completed: %s", record_id)
except Exception as exc:
record.pipeline_stage = GenerationRecordPipelineStage.FAILED.value
await mark_generation_record_failed_and_refund_once( await mark_generation_record_failed_and_refund_once(
db, db,
record=record, record=record,
error_message=str(e), error_message=(getattr(exc, "safe_message", None) or str(exc) or "图片生成失败"),
) )
_log_image_response(record_id, {}, str(e))
await db.commit() await db.commit()
logger.error(f"Image task failed: {record_id}, error: {e}") logger.error("Image task failed: %s, error: %s", record_id, exc, exc_info=True)
def stop(self): def stop(self):
"""Signal the queue to stop.""" """Signal the queue to stop."""
@@ -0,0 +1,175 @@
from __future__ import annotations
import json
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.video_upscale import (
ALL_PROCESSOR_KEYS,
VIDEO_UPSCALE_CONFIG_DESCRIPTION,
VIDEO_UPSCALE_CONFIG_KEY,
VIDEO_UPSCALE_CONFIG_VERSION,
VIDEO_UPSCALE_RESOLUTION_RANK,
VIDEO_UPSCALE_RESOLUTIONS,
VideoUpscaleProcessorKey,
normalize_video_upscale_resolution,
)
from app.models.system_config import SystemConfig
from app.schemas.video_upscale import VideoUpscaleConfigData
from app.utils.id_gen import generate_id
def default_video_upscale_config() -> dict[str, Any]:
return {
"enabled": False,
"version": VIDEO_UPSCALE_CONFIG_VERSION,
"delete_source_after_success": True,
"rules": [],
}
def _dump(data: dict[str, Any]) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":"), default=str)
def _load(value: str | None) -> dict[str, Any]:
if not value:
return default_video_upscale_config()
try:
data = json.loads(value)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"视频超分配置 JSON 损坏: {exc}") from exc
if not isinstance(data, dict):
raise HTTPException(status_code=500, detail="视频超分配置必须是 JSON 对象")
return data
def _simplify_stored_config(data: dict[str, Any]) -> dict[str, Any]:
"""兼容开发阶段已保存的旧页面配置,忽略 processors、比例和手工像素等多余字段。"""
rules: list[dict[str, Any]] = []
for raw_rule in data.get("rules") or []:
if not isinstance(raw_rule, dict):
continue
rules.append(
{
"target_resolution": raw_rule.get("target_resolution"),
"provider_generation_resolution": raw_rule.get("provider_generation_resolution"),
"processor_key": raw_rule.get("processor_key"),
"enabled": bool(raw_rule.get("enabled", True)),
}
)
return {
"enabled": bool(data.get("enabled", False)),
"version": max(1, int(data.get("version") or VIDEO_UPSCALE_CONFIG_VERSION)),
"delete_source_after_success": bool(data.get("delete_source_after_success", True)),
"rules": rules,
}
async def _get_record(db: AsyncSession) -> SystemConfig | None:
result = await db.execute(select(SystemConfig).where(SystemConfig.key == VIDEO_UPSCALE_CONFIG_KEY).limit(1))
return result.scalar_one_or_none()
def validate_video_upscale_config(data: dict[str, Any] | VideoUpscaleConfigData) -> dict[str, Any]:
raw = data.model_dump() if isinstance(data, VideoUpscaleConfigData) else _simplify_stored_config(dict(data))
try:
model = VideoUpscaleConfigData.model_validate(raw)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
normalized = model.model_dump()
seen: set[str] = set()
for rule in normalized.get("rules") or []:
target = normalize_video_upscale_resolution(rule.get("target_resolution"))
provider_resolution = normalize_video_upscale_resolution(rule.get("provider_generation_resolution"))
processor_key = str(rule.get("processor_key") or "").strip()
if target not in VIDEO_UPSCALE_RESOLUTIONS:
raise HTTPException(
status_code=400,
detail=f"不支持的客户目标分辨率: {target},仅支持 {''.join(VIDEO_UPSCALE_RESOLUTIONS)}",
)
if provider_resolution not in VIDEO_UPSCALE_RESOLUTIONS:
raise HTTPException(
status_code=400,
detail=f"不支持的实际生成分辨率: {provider_resolution},仅支持 {''.join(VIDEO_UPSCALE_RESOLUTIONS)}",
)
if processor_key not in ALL_PROCESSOR_KEYS:
raise HTTPException(status_code=400, detail=f"未注册的超分处理器: {processor_key}")
rule["target_resolution"] = target
rule["provider_generation_resolution"] = provider_resolution
rule["processor_key"] = processor_key
if VIDEO_UPSCALE_RESOLUTION_RANK[provider_resolution] > VIDEO_UPSCALE_RESOLUTION_RANK[target]:
raise HTTPException(
status_code=400,
detail=f"规则 {target} 的实际生成分辨率 {provider_resolution} 不能高于客户目标分辨率",
)
if processor_key == VideoUpscaleProcessorKey.VOLC_LARGE_MODEL_V1.value:
if target not in {"720p", "1080p", "2K"}:
raise HTTPException(status_code=400, detail="火山画质增强大模型目标分辨率仅支持 720p、1080p、2K")
if VIDEO_UPSCALE_RESOLUTION_RANK[provider_resolution] > VIDEO_UPSCALE_RESOLUTION_RANK["1080p"]:
raise HTTPException(status_code=400, detail="火山画质增强大模型输入视频最高支持 1080p")
if processor_key in {
VideoUpscaleProcessorKey.VOLC_STANDARD_V1.value,
VideoUpscaleProcessorKey.VOLC_PROFESSIONAL_V1.value,
} and VIDEO_UPSCALE_RESOLUTION_RANK[provider_resolution] > VIDEO_UPSCALE_RESOLUTION_RANK["2K"]:
raise HTTPException(status_code=400, detail="火山标准版/专业版输入视频最高支持 2K")
if rule.get("enabled"):
if target in seen:
raise HTTPException(status_code=400, detail=f"客户目标分辨率存在重复启用规则: {target}")
seen.add(target)
return normalized
async def get_video_upscale_config(db: AsyncSession) -> dict[str, Any]:
record = await _get_record(db)
data = default_video_upscale_config() if record is None else validate_video_upscale_config(_load(record.value))
return {
"id": record.id if record else None,
"key": VIDEO_UPSCALE_CONFIG_KEY,
"description": record.description if record else VIDEO_UPSCALE_CONFIG_DESCRIPTION,
"data": data,
"created_at": record.created_at if record else None,
"updated_at": record.updated_at if record else None,
}
async def get_runtime_video_upscale_config(db: AsyncSession) -> dict[str, Any]:
record = await _get_record(db)
if record is None:
return default_video_upscale_config()
return validate_video_upscale_config(_load(record.value))
async def save_video_upscale_config(db: AsyncSession, data: dict[str, Any] | VideoUpscaleConfigData) -> dict[str, Any]:
normalized = validate_video_upscale_config(data)
record = await _get_record(db)
old_version = 0
if record:
try:
old_version = int((_load(record.value).get("version") or 0))
except Exception:
old_version = 0
normalized["version"] = max(old_version + 1, VIDEO_UPSCALE_CONFIG_VERSION)
if record is None:
record = SystemConfig(
id=generate_id(),
key=VIDEO_UPSCALE_CONFIG_KEY,
value=_dump(normalized),
description=VIDEO_UPSCALE_CONFIG_DESCRIPTION,
)
db.add(record)
else:
record.value = _dump(normalized)
record.description = VIDEO_UPSCALE_CONFIG_DESCRIPTION
await db.flush()
return await get_video_upscale_config(db)
@@ -0,0 +1,61 @@
from __future__ import annotations
import os
from collections.abc import Iterable
from fastapi import HTTPException
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.video_upscale import VideoUpscaleTaskStatus
from app.models.video_upscale_task import VideoUpscaleTask
async def assert_no_recoverable_failed_upscale_tasks(
db: AsyncSession,
chat_task_ids: Iterable[str] | None = None,
*,
generation_record_ids: Iterable[str] | None = None,
) -> None:
"""阻止删除仍保留本地源视频、可由管理员人工恢复的超分失败任务。"""
chat_ids = list(dict.fromkeys(str(item) for item in (chat_task_ids or []) if item))
record_ids = list(dict.fromkeys(str(item) for item in (generation_record_ids or []) if item))
if not chat_ids and not record_ids:
return
owner_conditions = []
if chat_ids:
owner_conditions.append(VideoUpscaleTask.chat_generation_task_id.in_(chat_ids))
if record_ids:
owner_conditions.append(VideoUpscaleTask.generation_record_id.in_(record_ids))
result = await db.execute(
select(
VideoUpscaleTask.chat_generation_task_id,
VideoUpscaleTask.generation_record_id,
VideoUpscaleTask.source_local_path,
).where(
or_(*owner_conditions),
VideoUpscaleTask.status == VideoUpscaleTaskStatus.FAILED.value,
VideoUpscaleTask.source_local_path.isnot(None),
VideoUpscaleTask.source_deleted_at.is_(None),
)
)
recoverable = []
for chat_task_id, generation_record_id, source_local_path in result.all():
if source_local_path and os.path.isfile(str(source_local_path)):
recoverable.append(
{
"owner_type": "chat_generation_task" if chat_task_id else "generation_record",
"owner_id": str(chat_task_id or generation_record_id),
}
)
if recoverable:
raise HTTPException(
status_code=409,
detail={
"message": "当前生成任务异常暂不能删除",
"items": recoverable,
"task_count": len(recoverable),
},
)
@@ -0,0 +1,128 @@
from __future__ import annotations
import asyncio
import os
import subprocess
from pathlib import Path
from app.config import settings
from app.services.video_cover_service import get_ffmpeg_bin
from app.services.video_upscale.media_service import build_part_mp4_path, is_valid_file, probe_video, safe_remove
class LocalVideoUpscaleError(RuntimeError):
pass
def _build_filter(target_width: int, target_height: int) -> str:
if target_width <= 0 or target_height <= 0 or target_width % 2 or target_height % 2:
raise LocalVideoUpscaleError("目标宽高必须是正偶数")
return (
"hqdn3d=0.8:0.6:2.5:1.8,"
f"scale={target_width}:{target_height}:"
"force_original_aspect_ratio=increase:force_divisible_by=2:flags=lanczos,"
f"crop={target_width}:{target_height}:(iw-ow)/2:(ih-oh)/2,"
"unsharp=5:5:0.40:5:5:0.0,"
"eq=contrast=1.02:saturation=1.03,"
"setsar=1"
)
def _run_ffmpeg_sync(
*,
source_path: str,
part_path: str,
target_width: int,
target_height: int,
timeout_seconds: int,
) -> None:
ffmpeg_bin = get_ffmpeg_bin() # 明确复用 config.py 的 FFMPEG_BIN。
cmd = [
ffmpeg_bin,
"-hide_banner",
"-nostdin",
"-y",
"-i",
source_path,
"-map",
"0:v:0",
"-map",
"0:a?",
"-vf",
_build_filter(target_width, target_height),
"-c:v",
"libx264",
"-preset",
"slow",
"-crf",
"18",
"-profile:v",
"high",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
"-c:a",
"aac",
"-b:a",
"192k",
part_path,
]
try:
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(30, int(timeout_seconds)),
shell=False,
)
except subprocess.TimeoutExpired as exc:
raise LocalVideoUpscaleError(f"本地 FFmpeg 超分超时: {timeout_seconds}") from exc
except OSError as exc:
raise LocalVideoUpscaleError(f"本地 FFmpeg 无法启动: {exc}") from exc
if result.returncode != 0:
raise LocalVideoUpscaleError(f"本地 FFmpeg 超分失败: {(result.stderr or '').strip()[-4000:]}")
async def execute_local_ffmpeg_crop(
*,
source_path: str,
final_path: str,
target_width: int,
target_height: int,
timeout_seconds: int | None = None,
) -> str:
if not is_valid_file(source_path):
raise LocalVideoUpscaleError(f"超分源视频不存在或为空: {source_path}")
if is_valid_file(final_path):
info = await probe_video(final_path)
if info.width == target_width and info.height == target_height:
return final_path
os.makedirs(os.path.dirname(final_path), exist_ok=True)
part_path = build_part_mp4_path(final_path)
safe_remove(part_path)
try:
await asyncio.to_thread(
_run_ffmpeg_sync,
source_path=source_path,
part_path=part_path,
target_width=target_width,
target_height=target_height,
timeout_seconds=int(timeout_seconds or settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS),
)
if not is_valid_file(part_path):
raise LocalVideoUpscaleError("本地 FFmpeg 输出文件为空")
info = await probe_video(part_path)
if info.width != target_width or info.height != target_height:
raise LocalVideoUpscaleError(
f"本地 FFmpeg 输出尺寸不正确: {info.width}x{info.height},预期 {target_width}x{target_height}"
)
os.replace(part_path, final_path)
return final_path
except Exception:
safe_remove(part_path)
raise
@@ -0,0 +1,108 @@
from __future__ import annotations
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from app.models.chat_generation_task import ChatGenerationTask
from app.models.generation_record import GenerationRecord
from app.services.operation_log_service import log_operation_event
def _sanitize_url(value: Any) -> Any:
if not isinstance(value, str) or not value.startswith(("http://", "https://")):
return value
parts = urlsplit(value)
return urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
def _sanitize_detail(value: Any) -> Any:
if isinstance(value, dict):
result: dict[str, Any] = {}
for key, item in value.items():
key_text = str(key).lower()
if key_text in {"authorization", "api_key", "volc_api_key"}:
result[key] = "***"
elif "url" in key_text:
result[key] = _sanitize_url(item)
else:
result[key] = _sanitize_detail(item)
return result
if isinstance(value, list):
return [_sanitize_detail(item) for item in value]
return value
def _owner_context(task: Any | None, upscale_task: Any | None) -> dict[str, Any]:
if isinstance(task, ChatGenerationTask):
return {
"owner_type": "chat_generation_task",
"owner_id": task.id,
"chat_generation_task_id": task.id,
"generation_record_id": None,
"project_id": None,
"generation_mode": task.generation_mode,
}
if isinstance(task, GenerationRecord):
return {
"owner_type": "generation_record",
"owner_id": task.id,
"chat_generation_task_id": None,
"generation_record_id": task.id,
"project_id": task.project_id,
"generation_mode": None,
}
chat_task_id = getattr(upscale_task, "chat_generation_task_id", None) if upscale_task else None
generation_record_id = getattr(upscale_task, "generation_record_id", None) if upscale_task else None
return {
"owner_type": "chat_generation_task" if chat_task_id else "generation_record" if generation_record_id else None,
"owner_id": chat_task_id or generation_record_id,
"chat_generation_task_id": chat_task_id,
"generation_record_id": generation_record_id,
"project_id": None,
"generation_mode": None,
}
def log_video_upscale_event(
*,
event_type: str,
event_status: str = "success",
task: Any | None = None,
upscale_task: Any | None = None,
remote_request_id: str | None = None,
message: str | None = None,
detail: dict[str, Any] | None = None,
error: str | None = None,
) -> None:
"""统一复用 operation_log_service 写入视频超分步骤日志。"""
final_detail = _owner_context(task, upscale_task)
final_detail.update(_sanitize_detail(dict(detail or {})))
if upscale_task is not None:
final_detail.update(
{
"upscale_task_id": getattr(upscale_task, "id", None),
"processor_key": getattr(upscale_task, "processor_key", None),
"upscale_status": getattr(upscale_task, "status", None),
"upscale_stage": getattr(upscale_task, "stage", None),
"attempt_count": getattr(upscale_task, "attempt_count", None),
"failure_count": getattr(upscale_task, "failure_count", None),
"provider_task_id": getattr(upscale_task, "provider_task_id", None),
"input_source_type": getattr(upscale_task, "input_source_type", None),
"target_width": getattr(upscale_task, "target_width", None),
"target_height": getattr(upscale_task, "target_height", None),
}
)
owner_id = final_detail.get("owner_id")
log_operation_event(
domain="video_upscale",
event_type=event_type,
event_status=event_status,
source="service",
user_id=getattr(task, "user_id", None) if task is not None else None,
group_id=getattr(task, "parent_task_id", None) if task is not None else None,
task_id=owner_id,
remote_request_id=remote_request_id,
message=message,
detail=final_detail,
error=error,
)
@@ -0,0 +1,207 @@
from __future__ import annotations
import asyncio
import json
import os
import shutil
import subprocess
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
from urllib.parse import parse_qsl, urlsplit
import httpx
from app.config import settings
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.video_cover_service import get_ffmpeg_bin
@dataclass(slots=True)
class VideoMediaInfo:
width: int
height: int
duration_seconds: float
fps: float
codec_name: str | None = None
color_transfer: str | None = None
color_primaries: str | None = None
color_space: str | None = None
def build_part_mp4_path(final_path: str) -> str:
path = Path(final_path)
return str(path.with_name(f"{path.stem}.{uuid.uuid4().hex}.part.mp4"))
def safe_remove(path: str | None) -> bool:
if not path:
return True
try:
if os.path.exists(path):
os.remove(path)
return not os.path.exists(path)
except OSError:
return False
def is_valid_file(path: str | None) -> bool:
if not path:
return False
try:
return os.path.isfile(path) and os.path.getsize(path) > 0
except OSError:
return False
def get_ffprobe_bin() -> str:
ffmpeg = Path(get_ffmpeg_bin())
sibling = ffmpeg.with_name("ffprobe.exe" if ffmpeg.suffix.lower() == ".exe" else "ffprobe")
if sibling.exists():
return str(sibling)
found = shutil.which("ffprobe") or shutil.which("ffprobe.exe")
if found:
return found
raise RuntimeError("未找到 ffprobe,请确保其与 FFMPEG_BIN 同目录或已加入 PATH")
def _parse_fps(value: str | None) -> float:
if not value:
return 0.0
try:
if "/" in value:
left, right = value.split("/", 1)
denominator = float(right)
return float(left) / denominator if denominator else 0.0
return float(value)
except Exception:
return 0.0
def probe_video_sync(path: str, timeout_seconds: int = 30) -> VideoMediaInfo:
if not is_valid_file(path):
raise RuntimeError(f"视频文件不存在或为空: {path}")
cmd = [
get_ffprobe_bin(), "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,codec_name,avg_frame_rate,color_transfer,color_primaries,color_space:format=duration",
"-of", "json", path,
]
result = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(5, int(timeout_seconds)),
shell=False,
)
if result.returncode != 0:
raise RuntimeError(f"ffprobe 校验失败: {(result.stderr or '').strip()[-2000:]}")
try:
payload = json.loads(result.stdout or "{}")
stream = (payload.get("streams") or [])[0]
width = int(stream.get("width") or 0)
height = int(stream.get("height") or 0)
duration = float((payload.get("format") or {}).get("duration") or 0)
except Exception as exc:
raise RuntimeError(f"ffprobe 响应解析失败: {exc}") from exc
if width <= 0 or height <= 0:
raise RuntimeError("ffprobe 未读取到有效视频宽高")
return VideoMediaInfo(
width=width,
height=height,
duration_seconds=duration,
fps=_parse_fps(stream.get("avg_frame_rate")),
codec_name=stream.get("codec_name"),
color_transfer=stream.get("color_transfer"),
color_primaries=stream.get("color_primaries"),
color_space=stream.get("color_space"),
)
async def probe_video(path: str, timeout_seconds: int = 30) -> VideoMediaInfo:
return await asyncio.to_thread(probe_video_sync, path, timeout_seconds)
def parse_tos_signed_url_expiry(url: str | None) -> tuple[datetime | None, datetime | None]:
if not url:
return None, None
params = {key.lower(): value for key, value in parse_qsl(urlsplit(url).query, keep_blank_values=True)}
date_text = params.get("x-tos-date")
expires_text = params.get("x-tos-expires")
if not date_text or not expires_text:
return None, None
try:
signed_at = datetime.strptime(date_text, "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
expires_seconds = int(expires_text)
if expires_seconds <= 0:
return None, None
return signed_at, signed_at + timedelta(seconds=expires_seconds)
except Exception:
return None, None
async def probe_remote_url(url: str) -> bool:
timeout = httpx.Timeout(
connect=max(1, int(settings.VIDEO_UPSCALE_REMOTE_URL_PROBE_CONNECT_TIMEOUT_SECONDS or 3)),
read=max(1, int(settings.VIDEO_UPSCALE_REMOTE_URL_PROBE_READ_TIMEOUT_SECONDS or 5)),
write=5,
pool=5,
)
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
async with client.stream("GET", url, headers={"Range": "bytes=0-0"}) as response:
if response.status_code not in {200, 206}:
return False
async for chunk in response.aiter_bytes():
return bool(chunk)
return False
except Exception:
return False
def build_source_resource_url(source_local_path: str) -> str:
root = Path(settings.STORAGE_LOCAL_PATH).resolve()
path = Path(source_local_path).resolve()
try:
relative = path.relative_to(root).as_posix()
except ValueError as exc:
raise RuntimeError("超分源视频不在 STORAGE_LOCAL_PATH 下,无法生成签名 URL") from exc
return f"{settings.BASE_URL.rstrip('/')}/generate/videos/{relative}"
def build_local_source_signed_url(source_local_path: str, expire_seconds: int) -> str:
return build_resource_signed_url(
build_source_resource_url(source_local_path),
expire_seconds=max(600, int(expire_seconds)),
)
async def download_video_to_path(url: str, final_path: str, timeout_seconds: int) -> str:
if is_valid_file(final_path):
try:
await probe_video(final_path)
return final_path
except Exception:
safe_remove(final_path)
os.makedirs(os.path.dirname(final_path), exist_ok=True)
part_path = build_part_mp4_path(final_path)
timeout = httpx.Timeout(max(30, int(timeout_seconds)))
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
async with client.stream("GET", url) as response:
response.raise_for_status()
with open(part_path, "wb") as file_obj:
async for chunk in response.aiter_bytes(chunk_size=1024 * 1024):
file_obj.write(chunk)
if not is_valid_file(part_path):
raise RuntimeError("视频下载完成但临时文件为空")
await probe_video(part_path)
os.replace(part_path, final_path)
return final_path
except Exception:
safe_remove(part_path)
raise
@@ -0,0 +1,120 @@
from __future__ import annotations
from typing import Any, TypeAlias
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.enums.generation_status import GenerationRecordPipelineStage, GenerationStatus
from app.enums.generation_task import ChatGenerationPipelineStage, ChatGenerationTaskStatus
from app.models.chat_generation_task import ChatGenerationTask
from app.models.generation_record import GenerationRecord
from app.models.video_upscale_task import VideoUpscaleTask
from app.services.generation.ai.task_group_service import aggregate_parent_for_child
from app.services.generation.module_hook_service import notify_chat_generation_task_finished
VideoUpscaleOwner: TypeAlias = ChatGenerationTask | GenerationRecord
def owner_type(owner: VideoUpscaleOwner | None) -> str | None:
if isinstance(owner, ChatGenerationTask):
return "chat_generation_task"
if isinstance(owner, GenerationRecord):
return "generation_record"
return None
def owner_id(owner: VideoUpscaleOwner | None) -> str | None:
return str(getattr(owner, "id", "") or "") or None
def owner_is_generating(owner: VideoUpscaleOwner) -> bool:
if isinstance(owner, ChatGenerationTask):
return owner.status == ChatGenerationTaskStatus.GENERATING.value
return owner.status == GenerationStatus.generating.value
def owner_is_completed(owner: VideoUpscaleOwner) -> bool:
if isinstance(owner, ChatGenerationTask):
return owner.status == ChatGenerationTaskStatus.COMPLETED.value
return owner.status == GenerationStatus.completed.value
def set_owner_stage(owner: VideoUpscaleOwner, stage: str) -> None:
owner.pipeline_stage = stage
def upscale_stage_value(owner: VideoUpscaleOwner, chat_stage: ChatGenerationPipelineStage | str) -> str:
value = chat_stage.value if hasattr(chat_stage, "value") else str(chat_stage)
if isinstance(owner, ChatGenerationTask):
return value
try:
return GenerationRecordPipelineStage(value).value
except ValueError:
return value
async def load_upscale_owner(
db: AsyncSession,
upscale: VideoUpscaleTask,
*,
for_update: bool,
) -> VideoUpscaleOwner | None:
if upscale.chat_generation_task_id:
query = select(ChatGenerationTask).where(
ChatGenerationTask.id == upscale.chat_generation_task_id,
ChatGenerationTask.deleted_at.is_(None),
)
elif upscale.generation_record_id:
query = select(GenerationRecord).where(
GenerationRecord.id == upscale.generation_record_id,
GenerationRecord.deleted_at.is_(None),
)
else:
return None
if for_update:
query = query.with_for_update()
result = await db.execute(query.limit(1))
return result.scalar_one_or_none()
async def mark_owner_upscale_failed(
db: AsyncSession,
owner: VideoUpscaleOwner,
*,
error_message: str,
) -> None:
if isinstance(owner, ChatGenerationTask):
owner.status = ChatGenerationTaskStatus.FAILED.value
owner.pipeline_stage = ChatGenerationPipelineStage.UPSCALE_FAILED.value
owner.error_message = error_message
await notify_chat_generation_task_finished(db, owner)
await aggregate_parent_for_child(db, owner)
return
owner.status = GenerationStatus.failed.value
owner.pipeline_stage = GenerationRecordPipelineStage.UPSCALE_FAILED.value
owner.error_message = error_message
def restore_owner_for_upscale_retry(owner: VideoUpscaleOwner) -> None:
if isinstance(owner, ChatGenerationTask):
owner.status = ChatGenerationTaskStatus.GENERATING.value
owner.pipeline_stage = ChatGenerationPipelineStage.UPSCALE_QUEUED.value
else:
owner.status = GenerationStatus.generating.value
owner.pipeline_stage = GenerationRecordPipelineStage.UPSCALE_QUEUED.value
owner.error_message = None
def owner_context(owner: VideoUpscaleOwner | None) -> dict[str, Any]:
if owner is None:
return {}
return {
"owner_type": owner_type(owner),
"owner_id": owner_id(owner),
"chat_generation_task_id": owner.id if isinstance(owner, ChatGenerationTask) else None,
"generation_record_id": owner.id if isinstance(owner, GenerationRecord) else None,
"project_id": getattr(owner, "project_id", None),
"generation_mode": getattr(owner, "generation_mode", None),
}
@@ -0,0 +1,176 @@
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any, Iterable
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.enums.video_upscale import (
ALL_PROCESSOR_KEYS,
VIDEO_UPSCALE_RESOLUTIONS,
VideoUpscaleProcessorKey,
normalize_video_upscale_resolution,
video_upscale_short_edge_pixels,
)
from app.services.video_upscale.config_service import get_runtime_video_upscale_config
from app.services.video_upscale.log_service import log_video_upscale_event
def normalize_resolution(value: str | None) -> str:
return normalize_video_upscale_resolution(value)
def _even(value: float) -> int:
rounded = int(round(value))
if rounded < 2:
rounded = 2
return rounded if rounded % 2 == 0 else rounded + 1
def parse_aspect_ratio(value: str | None) -> tuple[int, int]:
text = str(value or "").strip()
try:
left, right = text.split(":", 1)
width = int(left)
height = int(right)
except Exception as exc:
raise HTTPException(status_code=400, detail=f"视频比例格式不合法: {text}") from exc
if width <= 0 or height <= 0:
raise HTTPException(status_code=400, detail=f"视频比例格式不合法: {text}")
return width, height
def calculate_target_dimensions(*, aspect_ratio: str, target_resolution: str) -> tuple[int, int]:
ratio_w, ratio_h = parse_aspect_ratio(aspect_ratio)
try:
pixels = video_upscale_short_edge_pixels(target_resolution)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if ratio_w >= ratio_h:
height = _even(pixels)
width = _even(height * ratio_w / ratio_h)
else:
width = _even(pixels)
height = _even(width * ratio_h / ratio_w)
return width, height
def _runtime_processor_snapshot(processor_key: str) -> dict[str, Any]:
if processor_key not in ALL_PROCESSOR_KEYS:
raise HTTPException(status_code=500, detail=f"未注册的超分处理器: {processor_key}")
is_local = processor_key == VideoUpscaleProcessorKey.LOCAL_FFMPEG_CROP_V1.value
return {
"max_attempts": max(1, int(settings.VIDEO_UPSCALE_MAX_ATTEMPTS or 3)),
"timeout_seconds": int(
settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS
if is_local
else settings.VIDEO_UPSCALE_REMOTE_POLL_TIMEOUT_SECONDS
),
"request_timeout_seconds": max(3, int(settings.VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS or 30)),
"poll_timeout_seconds": max(60, int(settings.VIDEO_UPSCALE_REMOTE_POLL_TIMEOUT_SECONDS or 7200)),
"poll_interval_seconds": max(5, int(settings.VIDEO_UPSCALE_REMOTE_POLL_INTERVAL_SECONDS or 30)),
"source_url_expire_seconds": max(600, int(settings.VIDEO_UPSCALE_LOCAL_SIGNED_URL_EXPIRE_SECONDS or 7200)),
"bitrate_level": "medium",
"scene": "aigc",
"fps": None,
"queue_id": None,
}
async def build_video_upscale_snapshot(
db: AsyncSession,
*,
target_resolution: str,
aspect_ratio: str,
supported_provider_resolutions: Iterable[str] | None = None,
) -> tuple[str, bool, str | None]:
config = await get_runtime_video_upscale_config(db)
original_resolution = normalize_resolution(target_resolution)
if original_resolution not in VIDEO_UPSCALE_RESOLUTIONS:
return str(target_resolution).strip(), False, None
if not config.get("enabled"):
log_video_upscale_event(
event_type="upscale_snapshot_bypassed",
event_status="bypassed",
detail={"reason": "global_disabled", "target_resolution": original_resolution, "aspect_ratio": aspect_ratio},
)
return original_resolution, False, None
matched: dict[str, Any] | None = None
for rule in config.get("rules") or []:
if not rule.get("enabled"):
continue
if normalize_resolution(rule.get("target_resolution")) == original_resolution:
matched = dict(rule)
break
if matched is None:
log_video_upscale_event(
event_type="upscale_snapshot_bypassed",
event_status="bypassed",
detail={"reason": "rule_not_matched", "target_resolution": original_resolution, "aspect_ratio": aspect_ratio},
)
return original_resolution, False, None
provider_resolution = normalize_resolution(matched.get("provider_generation_resolution"))
processor_key = str(matched.get("processor_key") or "").strip()
processor = _runtime_processor_snapshot(processor_key)
supported = {normalize_resolution(item) for item in (supported_provider_resolutions or []) if item}
if supported and provider_resolution not in supported:
raise HTTPException(
status_code=400,
detail=f"超分规则要求实际生成 {provider_resolution},但当前视频引擎不支持该分辨率",
)
target_width, target_height = calculate_target_dimensions(
aspect_ratio=aspect_ratio,
target_resolution=original_resolution,
)
target_short_edge_pixels = video_upscale_short_edge_pixels(original_resolution)
snapshot: dict[str, Any] = {
"config_version": int(config.get("version") or 1),
"target_resolution": original_resolution,
"provider_generation_resolution": provider_resolution,
"processor_key": processor_key,
"processor": processor,
"aspect_ratio": aspect_ratio,
"target_short_edge_pixels": target_short_edge_pixels,
"target_width": target_width,
"target_height": target_height,
"delete_source_after_success": bool(config.get("delete_source_after_success", True)),
"snapshot_created_at": datetime.now(timezone.utc).isoformat(),
}
canonical = json.dumps(snapshot, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
snapshot["snapshot_hash"] = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
log_video_upscale_event(
event_type="upscale_snapshot_matched",
detail={
"target_resolution": original_resolution,
"provider_generation_resolution": provider_resolution,
"processor_key": processor_key,
"aspect_ratio": aspect_ratio,
"target_width": target_width,
"target_height": target_height,
"delete_source_after_success": snapshot["delete_source_after_success"],
"config_version": snapshot["config_version"],
"snapshot_hash": snapshot["snapshot_hash"],
},
)
return provider_resolution, True, json.dumps(snapshot, ensure_ascii=False, separators=(",", ":"))
def parse_video_upscale_snapshot(value: str | None) -> dict[str, Any]:
if not value:
return {}
try:
data = json.loads(value)
except Exception as exc:
raise RuntimeError(f"超分快照不是合法 JSON: {exc}") from exc
if not isinstance(data, dict):
raise RuntimeError("超分快照必须是 JSON 对象")
return data
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,270 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import httpx
from app.config import settings
from app.enums.video_upscale import VideoUpscaleProcessorKey
class VolcMediaKitError(RuntimeError):
def __init__(
self,
message: str,
*,
code: str | None = None,
error_type: str | None = None,
param: str | None = None,
retryable: bool = True,
http_status: int | None = None,
request_id: str | None = None,
endpoint: str | None = None,
response_payload: dict[str, Any] | None = None,
):
super().__init__(message)
self.code = code
self.error_type = error_type
self.param = param
self.retryable = retryable
self.http_status = http_status
self.request_id = request_id
self.endpoint = endpoint
self.response_payload = response_payload or {}
def log_detail(self) -> dict[str, Any]:
return {
"endpoint": self.endpoint,
"http_status": self.http_status,
"request_id": self.request_id,
"error_code": self.code,
"error_type": self.error_type,
"error_param": self.param,
"error_message": str(self),
"retryable": self.retryable,
}
@dataclass(slots=True)
class VolcSubmitResult:
task_id: str
request_id: str | None
request_payload: dict[str, Any]
response_payload: dict[str, Any]
endpoint: str
@dataclass(slots=True)
class VolcQueryResult:
status: str
request_id: str | None
result: dict[str, Any] | None
error: dict[str, Any] | None
expires_at: int | None
response_payload: dict[str, Any]
endpoint: str
def _headers() -> dict[str, str]:
api_key = str(settings.VOLC_API_KEY or "").strip()
if not api_key:
raise VolcMediaKitError("VOLC_API_KEY 未配置", code="MissingApiKey", retryable=False)
return {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
def _base_url() -> str:
return str(settings.VOLC_MEDIAKIT_API_BASE or "https://mediakit.cn-beijing.volces.com").rstrip("/")
def _error_from_payload(
payload: dict[str, Any],
default_message: str,
*,
http_status: int | None = None,
endpoint: str | None = None,
) -> VolcMediaKitError:
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = str(error.get("code") or "") or None
error_type = str(error.get("type") or "") or None
param = str(error.get("param") or "") or None
message = str(error.get("message") or default_message)
retryable = True
if http_status in {400, 401, 403, 404, 422}:
retryable = False
if code in {"InvalidParameter", "Unauthorized", "Forbidden", "NotFound"} or error_type in {"BadRequest", "AuthError"}:
retryable = False
request_id = str(payload.get("request_id") or "") or None
return VolcMediaKitError(
message,
code=code,
error_type=error_type,
param=param,
retryable=retryable,
http_status=http_status,
request_id=request_id,
endpoint=endpoint,
response_payload=payload,
)
def build_submit_payload(
*,
processor_key: str,
video_url: str,
target_resolution: str,
target_width: int,
target_height: int,
processor: dict[str, Any],
client_token: str,
) -> tuple[str, dict[str, Any]]:
payload: dict[str, Any] = {
"video_url": video_url,
"bitrate_level": processor.get("bitrate_level") or "medium",
"client_token": client_token[:64],
}
if processor_key == VideoUpscaleProcessorKey.VOLC_LARGE_MODEL_V1.value:
endpoint = "/api/v1/tools/enhance-video-generative"
normalized = str(target_resolution or "").strip().lower()
if normalized not in {"720p", "1080p", "2k"}:
raise VolcMediaKitError(
"画质增强大模型仅支持目标分辨率 720p、1080p、2K",
code="InvalidResolution",
retryable=False,
endpoint=endpoint,
)
payload["resolution"] = normalized
elif processor_key in {
VideoUpscaleProcessorKey.VOLC_STANDARD_V1.value,
VideoUpscaleProcessorKey.VOLC_PROFESSIONAL_V1.value,
}:
endpoint = "/api/v1/tools/enhance-video"
payload["tool_version"] = "standard" if processor_key == VideoUpscaleProcessorKey.VOLC_STANDARD_V1.value else "professional"
payload["resolution_limit"] = min(int(target_width), int(target_height))
if processor_key == VideoUpscaleProcessorKey.VOLC_STANDARD_V1.value:
payload["scene"] = processor.get("scene") or "aigc"
else:
raise VolcMediaKitError(
f"不支持的火山超分处理器: {processor_key}",
code="UnsupportedProcessor",
retryable=False,
)
return endpoint, payload
async def submit_video_enhance(
*,
processor_key: str,
video_url: str,
target_resolution: str,
target_width: int,
target_height: int,
processor: dict[str, Any],
client_token: str,
) -> VolcSubmitResult:
endpoint, payload = build_submit_payload(
processor_key=processor_key,
video_url=video_url,
target_resolution=target_resolution,
target_width=target_width,
target_height=target_height,
processor=processor,
client_token=client_token,
)
timeout = max(3, int(processor.get("request_timeout_seconds") or settings.VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS))
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
response = await client.post(f"{_base_url()}{endpoint}", headers=_headers(), json=payload)
try:
data = response.json()
except Exception:
data = {"success": False, "error": {"message": response.text[:2000]}}
if response.status_code >= 400:
raise _error_from_payload(
data,
f"火山超分提交失败 HTTP {response.status_code}",
http_status=response.status_code,
endpoint=endpoint,
)
except VolcMediaKitError:
raise
except (httpx.TimeoutException, httpx.NetworkError) as exc:
raise VolcMediaKitError(
f"火山超分提交网络异常: {exc}",
code="NetworkError",
retryable=True,
endpoint=endpoint,
) from exc
if not bool(data.get("success")) or not data.get("task_id"):
raise _error_from_payload(data, "火山超分提交失败", endpoint=endpoint)
return VolcSubmitResult(
task_id=str(data["task_id"]),
request_id=str(data.get("request_id")) if data.get("request_id") else None,
request_payload=payload,
response_payload=data,
endpoint=endpoint,
)
async def query_task(task_id: str, *, request_timeout_seconds: int | None = None) -> VolcQueryResult:
endpoint = f"/api/v1/tasks/{task_id}"
timeout = max(3, int(request_timeout_seconds or settings.VIDEO_UPSCALE_REMOTE_REQUEST_TIMEOUT_SECONDS))
try:
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
response = await client.get(f"{_base_url()}{endpoint}", headers=_headers())
try:
data = response.json()
except Exception:
data = {"success": False, "error": {"message": response.text[:2000]}}
if response.status_code >= 400:
raise _error_from_payload(
data,
f"火山超分任务查询失败 HTTP {response.status_code}",
http_status=response.status_code,
endpoint=endpoint,
)
except VolcMediaKitError:
raise
except (httpx.TimeoutException, httpx.NetworkError) as exc:
raise VolcMediaKitError(
f"火山超分查询网络异常: {exc}",
code="NetworkError",
retryable=True,
endpoint=endpoint,
) from exc
if not bool(data.get("success")):
raise _error_from_payload(data, "火山超分任务查询失败", endpoint=endpoint)
status = str(data.get("status") or "").strip().lower()
if status not in {"running", "completed", "failed"}:
raise VolcMediaKitError(
f"火山超分返回未知任务状态: {status}",
code="UnknownStatus",
retryable=True,
request_id=str(data.get("request_id") or "") or None,
endpoint=endpoint,
response_payload=data,
)
expires_raw = data.get("expires_at")
try:
expires_at = int(expires_raw) if expires_raw is not None else None
except Exception:
expires_at = None
return VolcQueryResult(
status=status,
request_id=str(data.get("request_id")) if data.get("request_id") else None,
result=data.get("result") if isinstance(data.get("result"), dict) else None,
error=data.get("error") if isinstance(data.get("error"), dict) else None,
expires_at=expires_at,
response_payload=data,
endpoint=endpoint,
)
def is_remote_input_access_error(error: dict[str, Any] | None) -> bool:
if not error:
return False
text = " ".join(str(error.get(key) or "") for key in ("code", "message", "param", "type")).lower()
fragments = ("403", "forbidden", "url expired", "downloadfailed", "urldownloadfail", "download file", "url无法", "下载失败")
return any(fragment in text for fragment in fragments)
+40 -1
View File
@@ -18,6 +18,7 @@ CELERY_TASK_IMPORTS = (
"app.tasks.generation_poll_tasks", "app.tasks.generation_poll_tasks",
"app.tasks.generation_download_tasks", "app.tasks.generation_download_tasks",
"app.tasks.generation_recovery_tasks", "app.tasks.generation_recovery_tasks",
"app.tasks.video_upscale_tasks",
"app.tasks.hot_opening_replicate_tasks", "app.tasks.hot_opening_replicate_tasks",
"app.tasks.shot_replicate_tasks", "app.tasks.shot_replicate_tasks",
"app.tasks.shot_replicate_flow_tasks", "app.tasks.shot_replicate_flow_tasks",
@@ -52,6 +53,14 @@ def _beat_schedule() -> dict:
"priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER, "priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
}, },
} }
schedule["video-upscale-recovery-every-minute"] = {
"task": CeleryTaskName.VIDEO_UPSCALE_RECOVER.value,
"schedule": 60,
"options": {
"queue": RECOVERY_QUEUE,
"priority": settings.DOWNLOAD_TASK_PRIORITY_RECOVER,
},
}
schedule["private-portrait-sync-due-assets-every-minute"] = { schedule["private-portrait-sync-due-assets-every-minute"] = {
"task": CeleryTaskName.PRIVATE_PORTRAIT_SYNC_DUE_ASSETS.value, "task": CeleryTaskName.PRIVATE_PORTRAIT_SYNC_DUE_ASSETS.value,
"schedule": 60, "schedule": 60,
@@ -100,10 +109,24 @@ if broker_url:
"shot_replicate.split_one_segment": {"ignore_result": True}, "shot_replicate.split_one_segment": {"ignore_result": True},
"shot_replicate.start_image_prompt_optimize": {"ignore_result": True}, "shot_replicate.start_image_prompt_optimize": {"ignore_result": True},
"shot_replicate.start_video_prompt_optimize": {"ignore_result": True}, "shot_replicate.start_video_prompt_optimize": {"ignore_result": True},
CeleryTaskName.VIDEO_UPSCALE_EXECUTE_LOCAL.value: {
"ignore_result": True,
"soft_time_limit": max(60, int(settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS or 3600)) + 60,
"time_limit": max(60, int(settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS or 3600)) + 300,
},
CeleryTaskName.VIDEO_UPSCALE_SUBMIT_REMOTE.value: {"ignore_result": True},
CeleryTaskName.VIDEO_UPSCALE_POLL_REMOTE.value: {"ignore_result": True},
CeleryTaskName.VIDEO_UPSCALE_DOWNLOAD_REMOTE_RESULT.value: {"ignore_result": True},
CeleryTaskName.VIDEO_UPSCALE_FINALIZE.value: {"ignore_result": True},
CeleryTaskName.VIDEO_UPSCALE_RECOVER.value: {"ignore_result": True},
}, },
worker_prefetch_multiplier=1, worker_prefetch_multiplier=1,
broker_transport_options={ broker_transport_options={
"visibility_timeout": 3600, "visibility_timeout": max(
3600,
int(settings.VIDEO_UPSCALE_LOCAL_TIMEOUT_SECONDS or 3600) + 600,
int(settings.VIDEO_UPSCALE_REMOTE_RESULT_DOWNLOAD_TIMEOUT_SECONDS or 600) + 600,
),
"queue_order_strategy": "priority", "queue_order_strategy": "priority",
"priority_steps": list(range(10)), "priority_steps": list(range(10)),
"sep": ":", "sep": ":",
@@ -112,6 +135,22 @@ if broker_url:
CeleryTaskName.CHATAPI_CREATE.value: {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value}, CeleryTaskName.CHATAPI_CREATE.value: {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
CeleryTaskName.POLL_GENERATION.value: {"queue": CeleryQueue.GEN_PROVIDER_POLL.value}, CeleryTaskName.POLL_GENERATION.value: {"queue": CeleryQueue.GEN_PROVIDER_POLL.value},
CeleryTaskName.DOWNLOAD_GENERATION_RESULT.value: {"queue": CeleryQueue.GEN_RESULT_DOWNLOAD.value}, CeleryTaskName.DOWNLOAD_GENERATION_RESULT.value: {"queue": CeleryQueue.GEN_RESULT_DOWNLOAD.value},
CeleryTaskName.VIDEO_UPSCALE_EXECUTE_LOCAL.value: {
"queue": settings.VIDEO_UPSCALE_LOCAL_QUEUE or CeleryQueue.GEN_VIDEO_UPSCALE_LOCAL.value
},
CeleryTaskName.VIDEO_UPSCALE_SUBMIT_REMOTE.value: {
"queue": settings.VIDEO_UPSCALE_REMOTE_QUEUE or CeleryQueue.GEN_VIDEO_UPSCALE_REMOTE.value
},
CeleryTaskName.VIDEO_UPSCALE_POLL_REMOTE.value: {
"queue": settings.VIDEO_UPSCALE_REMOTE_QUEUE or CeleryQueue.GEN_VIDEO_UPSCALE_REMOTE.value
},
CeleryTaskName.VIDEO_UPSCALE_DOWNLOAD_REMOTE_RESULT.value: {
"queue": settings.VIDEO_UPSCALE_REMOTE_QUEUE or CeleryQueue.GEN_VIDEO_UPSCALE_REMOTE.value
},
CeleryTaskName.VIDEO_UPSCALE_FINALIZE.value: {
"queue": settings.VIDEO_UPSCALE_LOCAL_QUEUE or CeleryQueue.GEN_VIDEO_UPSCALE_LOCAL.value
},
CeleryTaskName.VIDEO_UPSCALE_RECOVER.value: {"queue": RECOVERY_QUEUE},
CeleryTaskName.DISPATCH_DUE_POLL.value: {"queue": RECOVERY_QUEUE}, CeleryTaskName.DISPATCH_DUE_POLL.value: {"queue": RECOVERY_QUEUE},
"hot_opening.start_image_prompt_optimize": {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value}, "hot_opening.start_image_prompt_optimize": {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
"hot_opening.start_video_prompt_optimize": {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value}, "hot_opening.start_video_prompt_optimize": {"queue": CeleryQueue.GEN_CHATAPI_CREATE.value},
@@ -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")
@@ -29,7 +29,7 @@ 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
@@ -560,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:
@@ -572,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:
@@ -52,6 +52,13 @@ async def _run_shot_split_once() -> Dict[str, Any]:
return await recover_shot_split_tasks_once(db) return await recover_shot_split_tasks_once(db)
async def _run_video_upscale_once() -> Dict[str, Any]:
from app.services.video_upscale.task_service import recover_video_upscale_tasks_once
async with async_session() as db:
return await recover_video_upscale_tasks_once(db)
async def _run_with_execution_lock( async def _run_with_execution_lock(
*, *,
lock_key: str, lock_key: str,
@@ -155,6 +162,8 @@ async def _run_startup_recovery_once() -> Dict[str, Any]:
- 创建/提词/视频分析 -> gen_chatapi_create - 创建/提词/视频分析 -> gen_chatapi_create
- provider poll -> gen_provider_poll - provider poll -> gen_provider_poll
- 下载/ffmpeg 切片 -> gen_result_download - 下载/ffmpeg 切片 -> gen_result_download
- 本地视频超分 -> gen_video_upscale_local
- 火山视频超分 -> gen_video_upscale_remote
恢复扫描本身只走 gen_recovery避免堵住业务 worker 恢复扫描本身只走 gen_recovery避免堵住业务 worker
""" """
return await _run_with_execution_lock( return await _run_with_execution_lock(
@@ -192,6 +201,12 @@ async def _run_startup_recovery_steps() -> Dict[str, Any]:
"download_recovery", "download_recovery",
_run_download_once, _run_download_once,
), ),
(
"video_upscale",
settings.VIDEO_UPSCALE_RECOVERY_LOCK_KEY,
"video_upscale_recovery",
_run_video_upscale_once,
),
] ]
for name, lock_key, log_context, runner in steps: for name, lock_key, log_context, runner in steps:
@@ -0,0 +1,109 @@
from __future__ import annotations
from typing import Any
from app.config import settings
from app.models.base import async_session
from app.services.video_upscale.task_service import (
recover_video_upscale_tasks_once,
run_finalize_upscale,
run_local_upscale,
run_remote_poll,
run_remote_result_download,
run_remote_submit,
)
from app.tasks.async_runner import run_async
from app.tasks.celery_app import celery_app
async def _run_local(upscale_task_id: str) -> None:
async with async_session() as db:
await run_local_upscale(db, upscale_task_id)
async def _run_submit(upscale_task_id: str, *, count_attempt: bool = True) -> None:
async with async_session() as db:
await run_remote_submit(db, upscale_task_id, count_attempt=count_attempt)
async def _run_poll(upscale_task_id: str) -> None:
async with async_session() as db:
await run_remote_poll(db, upscale_task_id)
async def _run_download(upscale_task_id: str) -> None:
async with async_session() as db:
await run_remote_result_download(db, upscale_task_id)
async def _run_finalize(upscale_task_id: str) -> None:
async with async_session() as db:
await run_finalize_upscale(db, upscale_task_id)
async def _run_recovery() -> dict[str, Any]:
async with async_session() as db:
return await recover_video_upscale_tasks_once(db)
if celery_app:
@celery_app.task(name="video_upscale.execute_local", bind=True, max_retries=2)
def execute_local(self, upscale_task_id: str) -> None:
try:
return run_async(_run_local(upscale_task_id))
except Exception as exc:
raise self.retry(exc=exc, countdown=max(5, int(settings.VIDEO_UPSCALE_RETRY_BACKOFF_SECONDS or 60)))
@celery_app.task(name="video_upscale.submit_remote", bind=True, max_retries=2)
def submit_remote(self, upscale_task_id: str, count_attempt: bool = True) -> None:
try:
return run_async(_run_submit(upscale_task_id, count_attempt=count_attempt))
except Exception as exc:
raise self.retry(exc=exc, countdown=max(5, int(settings.VIDEO_UPSCALE_RETRY_BACKOFF_SECONDS or 60)))
@celery_app.task(name="video_upscale.poll_remote", bind=True, max_retries=2)
def poll_remote(self, upscale_task_id: str) -> None:
try:
return run_async(_run_poll(upscale_task_id))
except Exception as exc:
raise self.retry(exc=exc, countdown=max(5, int(settings.VIDEO_UPSCALE_RETRY_BACKOFF_SECONDS or 60)))
@celery_app.task(name="video_upscale.download_remote_result", bind=True, max_retries=2)
def download_remote_result(self, upscale_task_id: str) -> None:
try:
return run_async(_run_download(upscale_task_id))
except Exception as exc:
raise self.retry(exc=exc, countdown=max(5, int(settings.VIDEO_UPSCALE_RETRY_BACKOFF_SECONDS or 60)))
@celery_app.task(name="video_upscale.finalize", bind=True, max_retries=2)
def finalize(self, upscale_task_id: str) -> None:
try:
return run_async(_run_finalize(upscale_task_id))
except Exception as exc:
raise self.retry(exc=exc, countdown=max(5, int(settings.VIDEO_UPSCALE_RETRY_BACKOFF_SECONDS or 60)))
@celery_app.task(name="video_upscale.recover_once", bind=True)
def recover_once(self) -> dict[str, Any]:
return run_async(_run_recovery())
else:
class _DisabledTask:
def delay(self, *args: Any, **kwargs: Any) -> None:
raise RuntimeError("Celery is disabled")
def apply_async(self, *args: Any, **kwargs: Any) -> None:
raise RuntimeError("Celery is disabled")
execute_local = _DisabledTask()
submit_remote = _DisabledTask()
poll_remote = _DisabledTask()
download_remote_result = _DisabledTask()
finalize = _DisabledTask()
recover_once = _DisabledTask()
@@ -14,6 +14,7 @@ class ProviderGenerationRecordLike(Protocol):
duration: int | None duration: int | None
aspect_ratio: str | None aspect_ratio: str | None
resolution: str | None resolution: str | None
provider_generation_resolution: str | None
image_size: str | None image_size: str | None
image_proportion: str | None image_proportion: str | None
image_px: str | None image_px: str | None
+54
View File
@@ -0,0 +1,54 @@
"""媒体文件 → base64 data URI 转换工具。"""
import base64
import mimetypes
import os
import httpx
from sqlalchemy import select
from app.config import settings
from app.models.system_config import SystemConfig
async def get_llm_media_as_base64(db=None) -> bool:
"""读取 SystemConfig 中的 llm_media_as_base64 设置。无 DB 连接时回退到 env。"""
if db is not None:
try:
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "llm_media_as_base64").limit(1)
)
config = result.scalar_one_or_none()
if config:
return config.value.lower() in ("true", "1", "yes")
except Exception:
pass
return settings.LLM_MEDIA_AS_BASE64
async def media_to_base64(url: str, fallback_mime: str = "image/png", max_mb: int = 20) -> str:
"""将任意媒体 URL/路径转为 base64 data URI。
- data: URI 原样返回
- http(s):// 下载后编码 max_mb 限制
- /uploads/xxx 或本地路径 读盘编码
"""
if url.startswith("data:"):
return url
if url.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.get(url)
size_mb = len(resp.content) / (1024 * 1024)
if size_mb > max_mb:
raise ValueError(f"媒体文件过大 ({size_mb:.1f}MB > {max_mb}MB)")
b64 = base64.b64encode(resp.content).decode()
mime = resp.headers.get("content-type") or fallback_mime
return f"data:{mime};base64,{b64}"
# 本地路径
relative_path = url.replace("/uploads/", "", 1).lstrip("/")
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, relative_path)
mime = mimetypes.guess_type(file_path)[0] or fallback_mime
with open(file_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
return f"data:{mime};base64,{b64}"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -28,8 +28,8 @@
} }
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-DWsZ8Hba.js"></script> <script type="module" crossorigin src="/assets/index-DYI2idb2.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DviWdElm.css"> <link rel="stylesheet" crossorigin href="/assets/index-CKeRPhR_.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -2
View File
@@ -295,8 +295,8 @@ export async function verifyCaptcha(captchaId: string, x: number): Promise<strin
return res.token; return res.token;
} }
// ── Site Info ───────────────────────────────────────────── // ── Site Info ─────────────────────────────────────────────
export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string }> { export async function getSiteInfo(): Promise<{ siteName: string; siteLogo: string; userAgreementPrivacyUrl: string; siteCopyright: string; operationManual: string; loginBgVideo: string }> {
if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2024 民众智创 版权所有', operationManual: '' }; if (USE_MOCK) return { siteName: 'VideoGen.AI', siteLogo: '', userAgreementPrivacyUrl: '', siteCopyright: '© 2024 民众智创 版权所有', operationManual: '', loginBgVideo: '' };
return api.get('/auth/site-info', false); return api.get('/auth/site-info', false);
} }
// ── Video Engines ───────────────────────────────────────── // ── Video Engines ─────────────────────────────────────────
+115 -257
View File
@@ -1,260 +1,139 @@
/* ========================================
登录页 视频全屏背景 + 居中卡片
======================================== */
.login-page { .login-page {
height: 100vh; height: 100vh;
max-height: 100vh; max-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #1a1a2e;
background-image: url(/backimage.png); background-image: url(/backimage.png);
background-size: cover; background-size: cover;
background-position: center; background-position: center;
background-repeat: no-repeat;
background-attachment: fixed;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
@media (min-width: 900px) { /* ---- 视频全屏背景 ---- */
.login-page { .login-bg-video {
flex-direction: row; position: absolute;
} inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
pointer-events: none;
} }
.login-bg-overlay { .login-bg-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
background: linear-gradient(135deg, rgba(240,244,255,0.9) 0%, rgba(232,236,248,0.85) 40%, rgba(240,240,255,0.9) 70%, rgba(248,249,255,0.95) 100%); background: linear-gradient(135deg, rgba(15,15,30,0.75) 0%, rgba(20,20,50,0.65) 100%);
z-index: 0; z-index: 0;
} }
.login-decoration { /* ---- 左上角 slogan ---- */
.login-slogan {
position: absolute; position: absolute;
border-radius: 50%; top: 40px;
filter: blur(40px); left: 48px;
z-index: 0; z-index: 2;
} }
.login-decoration-1 { .login-slogan-text {
width: 500px; font-size: 24px;
height: 500px; font-weight: 800;
background: radial-gradient(circle, rgba(99,102,241,0.08) 0%, transparent 70%); letter-spacing: 1px;
top: -150px; background: linear-gradient(90deg, #fff 0%, #c7d2fe 25%, #fff 50%, #c7d2fe 75%, #fff 100%);
right: -100px; background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: shinySlide 3s linear infinite;
text-shadow: none;
} }
.login-decoration-2 { @keyframes shinySlide {
width: 400px; 0% { background-position: 0% center; }
height: 400px; 100% { background-position: 200% center; }
background: radial-gradient(circle, rgba(139,92,246,0.06) 0%, transparent 70%);
bottom: -100px;
left: -80px;
filter: blur(50px);
} }
.login-left-section { /* ---- 登录卡片靠右 ---- */
.login-center {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; align-items: center;
justify-content: center; justify-content: flex-end;
padding: 32px 16px;
z-index: 1; z-index: 1;
padding: 24px 60px 24px 24px;
} }
@media (min-width: 900px) { /* ========================================
.login-left-section { 登录卡片
padding: 0 80px; ======================================== */
} .login-card {
}
.login-left-content {
width: 100%; width: 100%;
max-width: 400px;
border-radius: 20px !important;
box-shadow: 0 24px 80px rgba(0,0,0,0.25), 0 8px 32px rgba(99,102,241,0.1) !important;
border: 1px solid rgba(255,255,255,0.15) !important;
background: rgba(255,255,255,0.95) !important;
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
}
/* 卡片头部: logo + 站点名 */
.login-card-header {
text-align: center;
margin-bottom: 24px;
} }
.login-logo-row { .login-logo-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; justify-content: center;
margin-bottom: 20px; gap: 12px;
}
@media (max-width: 480px) {
.login-logo-row {
justify-content: center;
margin-bottom: 16px;
}
} }
.login-logo-img { .login-logo-img {
width: 52px; width: 40px;
height: 52px; height: 40px;
border-radius: 14px; border-radius: 12px;
objectFit: contain; object-fit: contain;
} }
.login-logo-placeholder { .login-logo-placeholder {
width: 52px; width: 40px;
height: 52px; height: 40px;
border-radius: 14px; border-radius: 12px;
background: linear-gradient(135deg, #6366f1, #8b5cf6); background: linear-gradient(135deg, #6366f1, #8b5cf6);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 8px 24px rgba(99,102,241,0.25); box-shadow: 0 6px 20px rgba(99,102,241,0.3);
} }
.login-site-name { .login-site-name {
color: #1e293b; font-size: 22px;
font-size: 28px;
font-weight: 800; font-weight: 800;
letter-spacing: -0.5px; color: #1e293b;
} letter-spacing: -0.3px;
@media (max-width: 480px) {
.login-site-name {
font-size: 24px;
}
}
.login-desc {
color: #64748b !important;
font-size: 17px !important;
max-width: 480px;
line-height: 1.8 !important;
margin: 0 !important;
}
@media (max-width: 899px) {
.login-desc {
display: none !important;
}
}
.login-features-list {
display: none;
flex-direction: column;
gap: 20px;
}
@media (min-width: 900px) {
.login-features-list {
display: flex;
}
}
.login-feature-card {
position: relative;
display: flex;
gap: 16px;
align-items: flex-start;
padding: 18px 22px;
border-radius: 14px;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(12px);
overflow: hidden;
}
.login-feature-icon {
width: 44px;
height: 44px;
border-radius: 12px;
flex-shrink: 0;
background: linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.15));
display: flex;
align-items: center;
justify-content: center;
color: #6366f1;
font-size: 20px;
}
.login-feature-title {
color: #1e293b !important;
font-size: 15px !important;
font-weight: 600 !important;
display: block !important;
margin-bottom: 4px !important;
}
.login-feature-desc {
color: #64748b !important;
font-size: 13px !important;
line-height: 1.6 !important;
}
.login-right-section {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 1;
padding: 16px 16px 60px;
position: relative;
}
.login-right-section-inner {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 440px;
}
.login-copyright-wrapper {
position: absolute;
bottom: 24px;
left: 0;
right: 0;
text-align: center;
}
.login-copyright {
color: #666;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.5px;
}
@media (min-width: 900px) {
.login-right-section {
padding: 40px 24px;
}
}
.login-card {
width: 100%;
max-width: 440px;
border-radius: 20px !important;
box-shadow: 0 20px 60px rgba(0,0,0,0.08) !important;
border: 1px solid #e2e8f0 !important;
background: #fff !important;
}
@media (max-width: 480px) {
.login-card {
border-radius: 16px !important;
}
.login-card .ant-card-body {
padding: 24px 20px !important;
}
}
.login-card-title {
text-align: center !important;
margin-bottom: 6px !important;
color: #1e293b !important;
font-weight: 700 !important;
} }
.login-card-subtitle { .login-card-subtitle {
display: block; display: block;
text-align: center; text-align: center;
margin-bottom: 28px; margin-bottom: 24px;
color: #94a3b8; color: #94a3b8;
font-size: 14px; font-size: 13px;
} }
/* ---- Tabs ---- */
.login-tabs { .login-tabs {
display: flex; display: flex;
gap: 0; gap: 0;
margin-bottom: 24px; margin-bottom: 20px;
background: #f1f5f9; background: #f1f5f9;
border-radius: 10px; border-radius: 10px;
padding: 4px; padding: 4px;
@@ -264,10 +143,10 @@
.login-tab { .login-tab {
flex: 1; flex: 1;
text-align: center; text-align: center;
padding: 10px 0; padding: 9px 0;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 13px;
font-weight: 400; font-weight: 400;
color: #64748b; color: #64748b;
background: transparent; background: transparent;
@@ -284,9 +163,9 @@
} }
.login-submit-btn { .login-submit-btn {
height: 48px !important; height: 46px !important;
border-radius: 10px !important; border-radius: 10px !important;
font-size: 16px !important; font-size: 15px !important;
font-weight: 600 !important; font-weight: 600 !important;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important; background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%) !important;
border: none !important; border: none !important;
@@ -294,19 +173,19 @@
} }
.login-code-btn { .login-code-btn {
height: 48px !important; height: 46px !important;
border-radius: 10 !important; border-radius: 10px !important;
border: 1.5px solid #e2e8f0 !important; border: 1.5px solid #e2e8f0 !important;
font-weight: 600 !important; font-weight: 600 !important;
min-width: 100px !important; min-width: 100px !important;
} }
.login-agreement { .login-agreement {
margin-bottom: 16px; margin-bottom: 14px;
} }
.login-agreement-text { .login-agreement-text {
font-size: 13px; font-size: 12px;
color: #64748b; color: #64748b;
} }
@@ -320,21 +199,33 @@
justify-content: center; justify-content: center;
} }
@media (min-width: 480px) {
.login-footer {
justify-content: flex-start;
}
}
.login-switch-btn { .login-switch-btn {
font-size: 13px !important; font-size: 13px !important;
color: #6366f1 !important; color: #6366f1 !important;
cursor: pointer; cursor: pointer;
} }
/* ---- Copyright ---- */
.login-copyright-wrapper {
position: absolute;
bottom: 20px;
left: 0;
right: 0;
text-align: center;
z-index: 2;
pointer-events: none;
}
.login-copyright {
color: rgba(255,255,255,0.5);
font-size: 12px;
text-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
/* ---- Ant Design overrides ---- */
.login-page .ant-input-affix-wrapper { .login-page .ant-input-affix-wrapper {
padding: 0 11px !important; padding: 0 11px !important;
height: 48px !important; height: 46px !important;
} }
.login-page .ant-input-affix-wrapper .ant-input-prefix { .login-page .ant-input-affix-wrapper .ant-input-prefix {
@@ -344,61 +235,28 @@
.login-page .ant-input { .login-page .ant-input {
padding-left: 11px !important; padding-left: 11px !important;
height: 48px !important; height: 46px !important;
} }
.login-code-btn { /* ========================================
height: 48px !important; 响应式
} ======================================== */
@media (max-width: 767px) { @media (max-width: 899px) {
.login-left-section { .login-slogan { top: 24px; left: 24px; }
padding: 28px 16px 12px; .login-slogan-text { font-size: 18px; }
} .login-center { padding: 80px 20px 24px; justify-content: center; }
.login-right-section {
padding: 12px 16px 32px;
}
.login-card {
max-width: 100%;
}
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.login-left-section { .login-slogan { top: 20px; left: 20px; }
padding: 24px 12px 8px; .login-slogan-text { font-size: 15px; }
text-align: center; .login-card .ant-card-body { padding: 24px 20px !important; }
} .login-card { border-radius: 16px !important; }
.login-right-section {
padding: 8px 12px 24px;
}
.shiny-text-container {
text-align: center;
}
.login-page .ant-input, .login-page .ant-input,
.login-page .ant-input-affix-wrapper { .login-page .ant-input-affix-wrapper { height: 44px !important; }
height: 44px !important; .login-code-btn { height: 44px !important; min-width: 90px !important; }
font-size: 14px !important; .login-submit-btn { height: 44px !important; }
}
.login-page .ant-input-affix-wrapper .ant-input {
height: 100% !important;
}
.login-code-btn {
height: 44px !important;
min-width: 90px !important;
font-size: 13px !important;
}
.login-submit-btn {
height: 44px !important;
font-size: 15px !important;
}
} }
@keyframes sliderShake { @keyframes sliderShake {
+75 -101
View File
@@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react';
import { Button, Form, Input, Card, Typography, message, Space, Checkbox } from 'antd'; import { Button, Form, Input, Card, Typography, message, Space, Checkbox } from 'antd';
import { import {
LockOutlined, ThunderboltOutlined, LockOutlined, ThunderboltOutlined,
PlayCircleOutlined, BulbOutlined, HistoryOutlined,
MobileOutlined, SafetyOutlined, MobileOutlined, SafetyOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
@@ -48,6 +47,7 @@ const LoginPage: React.FC = () => {
const initialInfo = getInitialSiteInfo(); const initialInfo = getInitialSiteInfo();
const [siteName, setSiteName] = useState(initialInfo.siteName); const [siteName, setSiteName] = useState(initialInfo.siteName);
const [siteLogo, setSiteLogo] = useState(initialInfo.siteLogo); const [siteLogo, setSiteLogo] = useState(initialInfo.siteLogo);
const [loginBgVideo, setLoginBgVideo] = useState('');
const [agreementPrivacyUrl, setAgreementPrivacyUrl] = useState(''); const [agreementPrivacyUrl, setAgreementPrivacyUrl] = useState('');
const [siteCopyright, setSiteCopyright] = useState(''); const [siteCopyright, setSiteCopyright] = useState('');
@@ -61,6 +61,7 @@ const LoginPage: React.FC = () => {
getSiteInfo().then(info => { getSiteInfo().then(info => {
setSiteName(info.siteName); setSiteName(info.siteName);
setSiteLogo(info.siteLogo); setSiteLogo(info.siteLogo);
setLoginBgVideo(info.loginBgVideo || '');
setAgreementPrivacyUrl(info.userAgreementPrivacyUrl); setAgreementPrivacyUrl(info.userAgreementPrivacyUrl);
setSiteCopyright(info.siteCopyright); setSiteCopyright(info.siteCopyright);
}).catch(() => {}); }).catch(() => {});
@@ -265,12 +266,6 @@ const LoginPage: React.FC = () => {
} }
}; };
const features = [
{ icon: <BulbOutlined />, title: 'AI 智能优化', desc: '输入原始提示词,AI 自动为您生成专业级视频、图片描述' },
{ icon: <PlayCircleOutlined />, title: '一键生成视频、图片', desc: '支持多种规格生成视频、图片' },
{ icon: <HistoryOutlined />, title: '项目维度管理', desc: '按项目行业分类视频、图片,支持多种行业' },
];
const inputStyle: React.CSSProperties = { const inputStyle: React.CSSProperties = {
background: '#fff', background: '#fff',
border: '1.5px solid #e2e8f0', border: '1.5px solid #e2e8f0',
@@ -293,71 +288,50 @@ const LoginPage: React.FC = () => {
return ( return (
<div className="login-page"> <div className="login-page">
{/* 背景视频/动图全屏铺满 */}
{loginBgVideo && (loginBgVideo.toLowerCase().endsWith('.gif') || loginBgVideo.toLowerCase().endsWith('.webp')) ? (
<img className="login-bg-video" src={loginBgVideo} alt="" />
) : loginBgVideo ? (
<video className="login-bg-video" autoPlay loop muted playsInline preload="auto">
<source src={loginBgVideo} type={loginBgVideo.endsWith('.webm') ? 'video/webm' : loginBgVideo.endsWith('.mov') ? 'video/quicktime' : 'video/mp4'} />
</video>
) : null}
<div className="login-bg-overlay" /> <div className="login-bg-overlay" />
<div className="login-decoration login-decoration-1" />
<div className="login-decoration login-decoration-2" />
<div className="login-left-section"> {/* 左上角 slogan */}
<Space direction="vertical" size={36} className="login-left-content"> <div className="login-slogan">
<div> <span className="login-slogan-text">AI赋能创意</span>
</div>
{/* 居中登录卡片 */}
<div className="login-center">
<Card className="login-card" styles={{ body: { padding: '32px 32px' } }}>
<div className="login-card-header">
<div className="login-logo-row"> <div className="login-logo-row">
{siteLogo ? ( {siteLogo ? (
<img src={siteLogo} alt="logo" className="login-logo-img" /> <img src={siteLogo} alt="logo" className="login-logo-img" />
) : ( ) : (
<div className="login-logo-placeholder"> <div className="login-logo-placeholder">
<ThunderboltOutlined style={{ fontSize: 26, color: '#fff' }} /> <ThunderboltOutlined style={{ fontSize: 20, color: '#fff' }} />
</div> </div>
)} )}
<span className="login-site-name">{siteName}</span> <span className="login-site-name">{siteName}</span>
</div> </div>
<div className="shiny-text-container"> </div>
<span className="shiny-text">AI赋能创意</span> <Typography.Text className="login-card-subtitle">
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作'}
</Typography.Text>
{mode !== 'register' && (
<div className="login-tabs">
{(['password', 'phone'] as const).map((t) => (
<div key={t} onClick={() => switchTab(t)}
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
{t === 'password' ? '密码登录' : '验证码登录'}
</div>
))}
</div> </div>
<Typography.Paragraph className="login-desc"> )}
AI <br />
</Typography.Paragraph>
</div>
<div className="login-features-list">
{features.map((f, i) => (
<div
key={i}
className="electric-border-card login-feature-card"
>
<div className="electric-border" />
<div className="electric-border-inner" />
<div style={{ position: 'relative', zIndex: 1 }}>
<div className="login-feature-icon">{f.icon}</div>
</div>
<div style={{ position: 'relative', zIndex: 1 }}>
<Typography.Text className="login-feature-title">{f.title}</Typography.Text>
<Typography.Text className="login-feature-desc">{f.desc}</Typography.Text>
</div>
</div>
))}
</div>
</Space>
</div>
<div className="login-right-section">
<div className="login-right-section-inner">
<Card className="login-card" styles={{ body: { padding: '36px 28px' } }}>
<Typography.Title level={3} className="login-card-title">
{mode === 'register' ? '创建账号' : '欢迎回来'}
</Typography.Title>
<Typography.Text className="login-card-subtitle">
{mode === 'register' ? '注册新账号,开始创作视频' : '登录您的账号,开始创作视频'}
</Typography.Text>
{mode !== 'register' && (
<div className="login-tabs">
{(['password', 'phone'] as const).map((t) => (
<div key={t} onClick={() => switchTab(t)}
className={`login-tab ${tab === t ? 'login-tab-active' : ''}`}>
{t === 'password' ? '密码登录' : '验证码登录'}
</div>
))}
</div>
)}
{mode === 'password' && ( {mode === 'password' && (
<Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}> <Form form={pwdForm} size="large" layout="vertical" onFinish={handlePasswordLogin}>
@@ -485,49 +459,49 @@ const LoginPage: React.FC = () => {
</Checkbox> </Checkbox>
</div> </div>
<div className="login-footer"> <div className="login-footer">
{mode === 'register' ? ( {mode === 'register' ? (
<Typography.Text <Typography.Text
className="login-switch-btn" className="login-switch-btn"
onClick={() => { onClick={() => {
setMode('password'); setMode('password');
setTab('password'); setTab('password');
setShowResend(false); setShowResend(false);
setLoginShowResend(false); setLoginShowResend(false);
setSliderVerified(false); setSliderVerified(false);
setShowSliderVerify(false); setShowSliderVerify(false);
setSliderKey(prev => prev + 1); setSliderKey(prev => prev + 1);
regForm.resetFields(); regForm.resetFields();
}} }}
> >
</Typography.Text> </Typography.Text>
) : ( ) : (
<Typography.Text <Typography.Text
className="login-switch-btn" className="login-switch-btn"
onClick={() => { onClick={() => {
setMode('register'); setMode('register');
setShowResend(false); setShowResend(false);
setLoginShowResend(false); setLoginShowResend(false);
setSliderVerified(false); setSliderVerified(false);
setShowSliderVerify(false); setShowSliderVerify(false);
setSliderKey(prev => prev + 1); setSliderKey(prev => prev + 1);
}} }}
> >
</Typography.Text> </Typography.Text>
)} )}
</div>
</Card>
</div>
{siteCopyright && (
<div className="login-copyright-wrapper">
<div className="login-copyright">
{siteCopyright}
</div>
</div> </div>
)} </Card>
</div> </div>
{siteCopyright && (
<div className="login-copyright-wrapper">
<div className="login-copyright">
{siteCopyright}
</div>
</div>
)}
</div> </div>
); );
}; };