前台页面/conversation修改积分计算逻辑
1、后台页面/credit-ratios,需要选择视频,上边是模型价格,下边增加传入视频价格,也要输入对应的倍率、基础积分、每秒积分 2、前台/conversation获取积分逻辑的接口也要增加对应的传入视频的比例 3、然后前台根据用户上传视频自动获取对应的比例加上对应模型选择的积分计算总积分 4、后台提交也要验证对应的积分是否正确 5、多个视频总时长需限制15s,最低视频时长2s
This commit is contained in:
Vendored
+1
-1
File diff suppressed because one or more lines are too long
-548
File diff suppressed because one or more lines are too long
-548
File diff suppressed because one or more lines are too long
-548
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-CbFNd7rH.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-B_ZbbEyL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -22,6 +22,9 @@ interface CreditRatio {
|
||||
ratio: number;
|
||||
baseCredits: number;
|
||||
perSecondCredits: number;
|
||||
inputVideoRatio: number;
|
||||
inputVideoBaseCredits: number;
|
||||
inputVideoPerSecondCredits: number;
|
||||
}
|
||||
|
||||
interface CreditRatioFormValues {
|
||||
@@ -31,6 +34,9 @@ interface CreditRatioFormValues {
|
||||
ratio: number;
|
||||
baseCredits: number;
|
||||
perSecondCredits?: number;
|
||||
inputVideoRatio?: number;
|
||||
inputVideoBaseCredits?: number;
|
||||
inputVideoPerSecondCredits?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_SIZES = ['2K', '4K'];
|
||||
@@ -117,7 +123,7 @@ const AdminCreditRatios: React.FC = () => {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
model_config_id: values.modelConfigId,
|
||||
gen_type: values.genType,
|
||||
resolution: values.resolution,
|
||||
@@ -125,6 +131,11 @@ const AdminCreditRatios: React.FC = () => {
|
||||
base_credits: values.baseCredits,
|
||||
per_second_credits: values.genType === 'image' ? 0 : (values.perSecondCredits || 0),
|
||||
};
|
||||
if (values.genType === 'video') {
|
||||
payload.input_video_ratio = values.inputVideoRatio ?? 1.0;
|
||||
payload.input_video_base_credits = values.inputVideoBaseCredits ?? 0;
|
||||
payload.input_video_per_second_credits = values.inputVideoPerSecondCredits ?? 0;
|
||||
}
|
||||
if (modal.ratio) {
|
||||
await saveCreditRatio({ id: modal.ratio.id, ...payload });
|
||||
message.success('已更新');
|
||||
@@ -174,10 +185,21 @@ const AdminCreditRatios: React.FC = () => {
|
||||
ratio: ratio.ratio,
|
||||
baseCredits: ratio.baseCredits,
|
||||
perSecondCredits: ratio.perSecondCredits,
|
||||
inputVideoRatio: ratio.inputVideoRatio,
|
||||
inputVideoBaseCredits: ratio.inputVideoBaseCredits,
|
||||
inputVideoPerSecondCredits: ratio.inputVideoPerSecondCredits,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ genType: 'video', ratio: 1.0, baseCredits: 60, perSecondCredits: 2 });
|
||||
form.setFieldsValue({
|
||||
genType: 'video',
|
||||
ratio: 1.0,
|
||||
baseCredits: 60,
|
||||
perSecondCredits: 2,
|
||||
inputVideoRatio: 1.0,
|
||||
inputVideoBaseCredits: 0,
|
||||
inputVideoPerSecondCredits: 0.5,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -216,13 +238,18 @@ const AdminCreditRatios: React.FC = () => {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '示例计算', key: 'example', width: 120,
|
||||
title: '示例计算', key: 'example', width: 140,
|
||||
render: (_: any, r: CreditRatio) => {
|
||||
let total: number;
|
||||
if (r.genType === 'image') {
|
||||
total = Math.round(r.baseCredits * r.ratio);
|
||||
} else {
|
||||
total = Math.round((r.baseCredits + r.perSecondCredits * 15) * r.ratio);
|
||||
if (r.inputVideoRatio && r.inputVideoPerSecondCredits) {
|
||||
total += Math.round(
|
||||
(r.inputVideoBaseCredits + r.inputVideoPerSecondCredits * 10) * r.inputVideoRatio
|
||||
);
|
||||
}
|
||||
}
|
||||
return <Typography.Text strong style={{ color: '#6366f1' }}>{total} 积分</Typography.Text>;
|
||||
},
|
||||
@@ -349,6 +376,31 @@ const AdminCreditRatios: React.FC = () => {
|
||||
</Form.Item>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{genType !== 'image' && (
|
||||
<div style={{
|
||||
marginTop: 8,
|
||||
padding: '12px 16px',
|
||||
backgroundColor: '#f5f5ff',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #e0e0ff',
|
||||
}}>
|
||||
<Typography.Text strong style={{ display: 'block', marginBottom: 8, color: '#4f46e5' }}>
|
||||
传入视频价格
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<Form.Item name="inputVideoRatio" label="倍率" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频倍率' }]}>
|
||||
<InputNumber min={0} max={10} step={0.1} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="inputVideoBaseCredits" label="基础积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频基础积分' }]}>
|
||||
<InputNumber min={0} max={500} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="inputVideoPerSecondCredits" label="每秒积分" style={{ flex: 1, marginBottom: 0 }} rules={[{ required: true, message: '请输入传入视频每秒积分' }]}>
|
||||
<InputNumber min={0} max={50} style={{ width: '100%' }} size="large" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""6idufv2q1c_add_credits_ratio_增加上传视频积分规则
|
||||
|
||||
Revision ID: a1b2c3d4e5f7
|
||||
Revises: 6idufv2q1c
|
||||
Create Date: 2026-07-01 00:00:00.000000
|
||||
|
||||
该文件包含 2026-07-01 的数据库迁移内容:
|
||||
1. 积分规则表增加传入视频计费字段(input_video_ratio, input_video_base_credits, input_video_per_second_credits)
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'a1b2c3d4e5f7'
|
||||
down_revision: Union[str, None] = '6idufv2q1c'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ========================================
|
||||
# 2026-07-01 - 积分规则表增加传入视频计费字段
|
||||
# ========================================
|
||||
op.add_column('credit_ratios', sa.Column('input_video_ratio', sa.Float(), server_default='1.0', nullable=False))
|
||||
op.add_column('credit_ratios', sa.Column('input_video_base_credits', sa.Float(), server_default='0.0', nullable=False))
|
||||
op.add_column('credit_ratios', sa.Column('input_video_per_second_credits', sa.Float(), server_default='0.5', nullable=False))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ========================================
|
||||
# 2026-07-01 - 积分规则表增加传入视频计费字段(回滚)
|
||||
# ========================================
|
||||
op.drop_column('credit_ratios', 'input_video_per_second_credits')
|
||||
op.drop_column('credit_ratios', 'input_video_base_credits')
|
||||
op.drop_column('credit_ratios', 'input_video_ratio')
|
||||
@@ -22,3 +22,6 @@ class CreditRatio(Base, TimestampMixin):
|
||||
ratio: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
base_credits: Mapped[float] = mapped_column(Float, default=80.0)
|
||||
per_second_credits: Mapped[float] = mapped_column(Float, default=2.0)
|
||||
input_video_ratio: Mapped[float] = mapped_column(Float, default=1.0)
|
||||
input_video_base_credits: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
input_video_per_second_credits: Mapped[float] = mapped_column(Float, default=0.5)
|
||||
|
||||
@@ -43,6 +43,24 @@ class CreditRatioCreate(BaseModel):
|
||||
description="视频每秒积分。图片规则通常为 0",
|
||||
examples=[2.0],
|
||||
)
|
||||
input_video_ratio: float = Field(
|
||||
default=1.0,
|
||||
ge=0,
|
||||
description="传入视频积分倍率。视频生成时,用户上传参考视频的额外积分倍率",
|
||||
examples=[1.0],
|
||||
)
|
||||
input_video_base_credits: float = Field(
|
||||
default=0.0,
|
||||
ge=0,
|
||||
description="传入视频基础积分。视频生成时,用户上传参考视频的基础积分",
|
||||
examples=[0.0],
|
||||
)
|
||||
input_video_per_second_credits: float = Field(
|
||||
default=0.5,
|
||||
ge=0,
|
||||
description="传入视频每秒积分。视频生成时,用户上传参考视频每秒消耗的积分",
|
||||
examples=[0.5],
|
||||
)
|
||||
|
||||
|
||||
class CreditRatioOut(CreditRatioCreate):
|
||||
|
||||
@@ -14,6 +14,7 @@ class GenerationAIReference(BaseModel):
|
||||
"url": "https://example.com/reference.png",
|
||||
"type": "image",
|
||||
"name": "参考图.png",
|
||||
"duration": 5.0,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -33,6 +34,12 @@ class GenerationAIReference(BaseModel):
|
||||
description="参考素材名称,前端展示用,可为空",
|
||||
examples=["参考图.png"],
|
||||
)
|
||||
duration: float | None = Field(
|
||||
None,
|
||||
ge=0,
|
||||
description="视频素材时长(秒)。type=video 时使用,用于视频素材计费和时长校验",
|
||||
examples=[5.0],
|
||||
)
|
||||
|
||||
|
||||
class GenerationAITaskCreate(BaseModel):
|
||||
|
||||
@@ -65,6 +65,7 @@ async def calc_video_credits(
|
||||
duration: int,
|
||||
resolution: str,
|
||||
engine_id: str | None = None,
|
||||
input_video_duration: float | None = None,
|
||||
) -> float:
|
||||
"""Calculate video credits using CreditRatio table, with fallback to hardcoded.
|
||||
|
||||
@@ -72,8 +73,9 @@ async def calc_video_credits(
|
||||
1. gen_type=video + engine_id + resolution 精确规则;
|
||||
2. gen_type=video + resolution 下 base_credits/per_second_credits 最高规则;
|
||||
3. 原硬编码默认算法。
|
||||
|
||||
input_video_duration: 用户上传的参考视频总时长(秒),不为空时额外计费
|
||||
"""
|
||||
# 如果engine_id为空,默认查询权重最高的视频引擎积分规则
|
||||
if not engine_id:
|
||||
video_engines_result = await db.execute(
|
||||
select(VideoEngine.id)
|
||||
@@ -89,13 +91,21 @@ async def calc_video_credits(
|
||||
engine_id=engine_id,
|
||||
)
|
||||
if ratio:
|
||||
return round((ratio.base_credits + ratio.per_second_credits * duration) * ratio.ratio, 2)
|
||||
base_cost = (ratio.base_credits + ratio.per_second_credits * duration) * ratio.ratio
|
||||
if input_video_duration and input_video_duration > 0:
|
||||
input_video_cost = (
|
||||
ratio.input_video_base_credits + ratio.input_video_per_second_credits * input_video_duration
|
||||
) * ratio.input_video_ratio
|
||||
base_cost += input_video_cost
|
||||
return round(base_cost, 2)
|
||||
|
||||
# Fallback
|
||||
base = 60.0
|
||||
duration_cost = duration * 2.0
|
||||
multiplier = {"480p": 1, "1080p": 2, "720p": 1.5}.get(resolution, 1.0)
|
||||
return round((base + duration_cost) * multiplier, 2)
|
||||
total = (base + duration_cost) * multiplier
|
||||
if input_video_duration and input_video_duration > 0:
|
||||
total += input_video_duration * 0.5 * multiplier
|
||||
return round(total, 2)
|
||||
|
||||
|
||||
def calc_credits(duration: int, resolution: str) -> float:
|
||||
|
||||
@@ -301,6 +301,18 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
|
||||
raise HTTPException(status_code=400, detail=f"视频时长不支持: {duration}")
|
||||
if engine.max_duration and duration > engine.max_duration:
|
||||
raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration} 秒")
|
||||
|
||||
input_video_duration = 0.0
|
||||
if refs:
|
||||
video_refs = [r for r in refs if r.get("type") == "video"]
|
||||
for ref in video_refs:
|
||||
ref_duration = float(ref.get("duration") or 0)
|
||||
if ref_duration < 2:
|
||||
raise HTTPException(status_code=400, detail=f"视频素材最短不能少于 2 秒")
|
||||
input_video_duration += ref_duration
|
||||
if input_video_duration > 15:
|
||||
raise HTTPException(status_code=400, detail=f"所有视频素材总时长不能超过 15 秒,当前 {input_video_duration:.1f} 秒")
|
||||
|
||||
media_billing = await charge_generation_media_by_params(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
@@ -309,6 +321,7 @@ async def create_async_generation_task(db: AsyncSession, current_user: User, req
|
||||
duration=duration,
|
||||
resolution=resolution,
|
||||
engine_id=engine.id,
|
||||
input_video_duration=input_video_duration if input_video_duration > 0 else None,
|
||||
project_name="AI生成任务",
|
||||
description_prefix="AI创作-",
|
||||
owner_type=OWNER_CHAT_GENERATION_TASK,
|
||||
|
||||
@@ -460,6 +460,7 @@ async def charge_generation_media_by_params(
|
||||
duration: int | None = None,
|
||||
resolution: str | None = None,
|
||||
engine_id: str | None = None,
|
||||
input_video_duration: float | None = None,
|
||||
project_name: str | None = None,
|
||||
description_prefix: str = "AI创作-",
|
||||
owner_type: str = OWNER_CHAT_GENERATION_TASK,
|
||||
@@ -522,7 +523,11 @@ async def charge_generation_media_by_params(
|
||||
)
|
||||
)
|
||||
elif gen_type == "video":
|
||||
amount = await calc_video_credits(db, duration or 5, resolution or "720p", engine_id=engine_id)
|
||||
amount = await calc_video_credits(
|
||||
db, duration or 5, resolution or "720p",
|
||||
engine_id=engine_id,
|
||||
input_video_duration=input_video_duration,
|
||||
)
|
||||
items.append(
|
||||
await deduct_credits_locked_once(
|
||||
db,
|
||||
|
||||
File diff suppressed because one or more lines are too long
+442
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -28,8 +28,8 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-B5pqnZWD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BhPFzWLH.css">
|
||||
<script type="module" crossorigin src="/assets/index-lZSCkINs.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bi8mcSs8.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -63,6 +63,7 @@ interface MediaReference {
|
||||
name: string;
|
||||
type: 'image' | 'video';
|
||||
url: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
@@ -280,6 +281,9 @@ const AIChatPage: React.FC = () => {
|
||||
perSecondCredits: 2,
|
||||
baseCredits: 60,
|
||||
ratio: 1,
|
||||
inputVideoRatio: 1,
|
||||
inputVideoBaseCredits: 0,
|
||||
inputVideoPerSecondCredits: 0.5,
|
||||
};
|
||||
if (videoResolution === '1080p') {
|
||||
config.ratio = 2;
|
||||
@@ -304,13 +308,21 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// 根据配置计算积分
|
||||
if (mediaType === 'video') {
|
||||
// 视频:(秒数 × perSecondCredits + baseCredits) × ratio
|
||||
return Math.round((videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio);
|
||||
let total = Math.round((videoDuration * config.perSecondCredits + config.baseCredits) * config.ratio);
|
||||
// 传入视频积分
|
||||
const inputVideoDuration = currentMedia
|
||||
.filter((m) => m.type === 'video')
|
||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||
if (inputVideoDuration > 0) {
|
||||
const inputVideoCost = Math.round(
|
||||
((config.inputVideoBaseCredits || 0) + (config.inputVideoPerSecondCredits || 0) * inputVideoDuration) * (config.inputVideoRatio || 1)
|
||||
);
|
||||
total += inputVideoCost;
|
||||
}
|
||||
return total;
|
||||
} else {
|
||||
// 图片:baseCredits × ratio
|
||||
return config.baseCredits * config.ratio;
|
||||
@@ -836,6 +848,22 @@ const AIChatPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getVideoDuration = (file: File): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.preload = 'metadata';
|
||||
video.onloadedmetadata = () => {
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
resolve(video.duration);
|
||||
};
|
||||
video.onerror = () => {
|
||||
window.URL.revokeObjectURL(video.src);
|
||||
reject(new Error('无法获取视频时长'));
|
||||
};
|
||||
video.src = URL.createObjectURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpload = async (file: File) => {
|
||||
// 验证文件类型
|
||||
const isImage = file.type.startsWith('image/');
|
||||
@@ -879,6 +907,28 @@ const AIChatPage: React.FC = () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取视频时长并验证
|
||||
let videoDuration = 0;
|
||||
if (isVideo) {
|
||||
try {
|
||||
videoDuration = await getVideoDuration(file);
|
||||
if (videoDuration < 2) {
|
||||
message.error('视频素材最短不能少于 2 秒');
|
||||
return false;
|
||||
}
|
||||
const existingVideoDuration = currentMedia
|
||||
.filter((m) => m.type === 'video')
|
||||
.reduce((sum, m) => sum + (m.duration || 0), 0);
|
||||
if (existingVideoDuration + videoDuration > 15) {
|
||||
message.error(`所有视频素材总时长不能超过 15 秒,当前 ${(existingVideoDuration + videoDuration).toFixed(1)} 秒`);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
message.error('无法获取视频时长');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 设置上传状态
|
||||
setUploading(true);
|
||||
|
||||
@@ -893,6 +943,7 @@ const AIChatPage: React.FC = () => {
|
||||
type: mediaType,
|
||||
url: res.url,
|
||||
label: '',
|
||||
...(isVideo && { duration: videoDuration }),
|
||||
}];
|
||||
const labels = generateMediaLabels(newList);
|
||||
setCurrentMedia(newList.map((m, i) => ({ ...m, label: labels[i] })));
|
||||
|
||||
@@ -32,7 +32,7 @@ interface AppState {
|
||||
};
|
||||
enginesele: any;
|
||||
inputValue: string;
|
||||
currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string }[];
|
||||
currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string; duration?: number }[];
|
||||
|
||||
fetchProjects: () => Promise<void>;
|
||||
createProject: (name: string, industry: Industry) => Promise<Project>;
|
||||
@@ -59,7 +59,7 @@ interface AppState {
|
||||
setVideoResolution: (resolution: string) => void;
|
||||
setEnginesele: (enginesele: any) => void;
|
||||
setInputValue: (inputValue: string) => void;
|
||||
setCurrentMedia: (currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string }[]) => void;
|
||||
setCurrentMedia: (currentMedia: { name: string; type: 'image' | 'video'; url: string; label: string; duration?: number }[]) => void;
|
||||
resetGenerationConfig: () => void;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user