一键成片优化

This commit is contained in:
Azmat@qq.com
2026-09-04 18:16:43 +08:00
parent ee3ddb49b4
commit 0d042edc73
6 changed files with 130 additions and 7 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ class ModelConfigSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = ModelConfig model = ModelConfig
fields = ["id", "provider", "name", "display_name", "capability", "endpoint", "unit_price", "status", "metadata"] fields = ["id", "provider", "name", "display_name", "capability", "endpoint", "unit_price", "status", "is_default", "metadata"]
read_only_fields = fields read_only_fields = fields
@@ -103,7 +103,70 @@ def _script_generation_inflight(project: Project) -> bool:
).exists() ).exists()
def estimate_quick_create_points(
*,
team,
text_model: ModelConfig,
image_model: ModelConfig,
video_model: ModelConfig,
aspect_ratio: str,
resolution: str,
total_duration: int,
):
"""与前端按钮预估同口径:脚本挂牌 + 默认出图×场次 + 视频挂牌秒价×时长(无引用视频)。"""
from decimal import Decimal
from apps.billing.pricing import quote_flat, quote_video_estimate
scene_count = max(1, int(total_duration) // 15)
script_points = quote_flat(text_model, units=1, team=team).points
image_points = quote_flat(image_model, units=scene_count, team=team).points
_tokens, video_quote = quote_video_estimate(
video_model,
aspect_ratio=aspect_ratio,
resolution=resolution,
duration=int(total_duration),
references=[],
team=team,
)
return Decimal(script_points) + Decimal(image_points) + Decimal(video_quote.points)
def assert_quick_create_balance(
*,
team,
text_model: ModelConfig,
image_model: ModelConfig,
video_model: ModelConfig,
aspect_ratio: str,
resolution: str,
total_duration: int,
):
"""提交前确认可用积分 ≥ 预估总额;不足则抛 ValueError(中文提示)。不预留,只门禁。"""
from decimal import Decimal
from apps.billing.models import CreditAccount
needed = estimate_quick_create_points(
team=team,
text_model=text_model,
image_model=image_model,
video_model=video_model,
aspect_ratio=aspect_ratio,
resolution=resolution,
total_duration=total_duration,
)
account = CreditAccount.objects.filter(team=team).first()
available = (account.balance - account.reserved_balance) if account else Decimal("0")
if available < needed:
raise ValueError(
f"团队余额不足,预计需要 {needed} 积分,当前可用 {available} 积分,请充值后再开始"
)
return needed, available
def get_quick_script_model() -> ModelConfig | None: def get_quick_script_model() -> ModelConfig | None:
"""极速成片固定复用专业创作的豆包 Seed 2.1 Pro 脚本模型。""" """极速成片固定复用专业创作的豆包 Seed 2.1 Pro 脚本模型。"""
return ( return (
ModelConfig.objects.select_related("provider") ModelConfig.objects.select_related("provider")
+16
View File
@@ -589,6 +589,22 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
) )
team = self.get_team() team = self.get_team()
# 开工前门禁:可用积分必须盖住「脚本+出图粗估+视频」挂牌合计(与按钮预估同口径)。
# 不在这里预留整笔,后续各阶段各自 reserve;这里只避免积不够半路挂掉。
from .services.quick_create import assert_quick_create_balance
try:
assert_quick_create_balance(
team=team,
text_model=text_model,
image_model=image_model,
video_model=video_model,
aspect_ratio=aspect_ratio,
resolution=resolution,
total_duration=total_duration,
)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
reused_assets = [] reused_assets = []
if source_product_id: if source_product_id:
try: try:
+46 -4
View File
@@ -29,6 +29,7 @@ import {
estimateCost, estimateCost,
FC_MODELS, FC_MODELS,
modelLabel, modelLabel,
pointsPerCallFromCatalog,
pointsPerImageFromCatalog, pointsPerImageFromCatalog,
type BillingRates, type BillingRates,
} from "../components/free-create/constants"; } from "../components/free-create/constants";
@@ -60,6 +61,10 @@ const QUICK_RESOLUTIONS = [
{ value: "4k", label: "4K 超清" }, { value: "4k", label: "4K 超清" },
]; ];
const QUICK_DURATIONS = [15, 30, 45, 60]; const QUICK_DURATIONS = [15, 30, 45, 60];
/** 与后端 QUICK_SCRIPT_MODEL_NAME 一致 */
const QUICK_SCRIPT_MODEL = "doubao-seed-2-1-pro-260628";
const QUICK_SCRIPT_POINTS_FALLBACK = 100;
const QUICK_IMAGE_POINTS_FALLBACK = 20;
function modelResolutions(config: ModelConfig | undefined) { function modelResolutions(config: ModelConfig | undefined) {
const capabilities = (config?.metadata?.capabilities || {}) as Record<string, unknown>; const capabilities = (config?.metadata?.capabilities || {}) as Record<string, unknown>;
@@ -413,6 +418,24 @@ export function QuickCreatePage({
onNotify?.("info", "请选择商品品类,脚本会按品类来写"); onNotify?.("info", "请选择商品品类,脚本会按品类来写");
return; return;
} }
// 与后端门禁同口径:可用积分不够预估总额就不提交。
if (estimatedPoints > 0) {
try {
const summary = await api.billingSummary();
const balance = Number(summary.account?.balance || 0);
const reserved = Number(summary.account?.reserved_balance || 0);
const available = balance - reserved;
if (available < estimatedPoints) {
onNotify?.(
"info",
`积分不足:预计需要 ${estimatedPoints} 积分,当前可用 ${Math.max(0, Math.floor(available))} 积分,请充值后再开始`,
);
return;
}
} catch {
/* 余额接口失败时仍交给后端硬拦 */
}
}
setSubmitting(true); setSubmitting(true);
setJob(null); setJob(null);
setServiceUnavailable(false); setServiceUnavailable(false);
@@ -465,6 +488,8 @@ export function QuickCreatePage({
setServiceUnavailable(true); setServiceUnavailable(true);
setUnavailableMessage(error.message || "一键成片服务暂不可用,请稍后再试"); setUnavailableMessage(error.message || "一键成片服务暂不可用,请稍后再试");
onNotify?.("info", error.message || "一键成片服务暂不可用,请稍后再试"); onNotify?.("info", error.message || "一键成片服务暂不可用,请稍后再试");
} else if (error instanceof ApiError && error.status === 400 && /余额不足|积分不足/.test(error.message || "")) {
onNotify?.("info", error.message);
} else { } else {
onNotify?.("error", error instanceof Error ? error.message : "一键成片启动失败"); onNotify?.("error", error instanceof Error ? error.message : "一键成片启动失败");
} }
@@ -572,14 +597,31 @@ export function QuickCreatePage({
{ ratio: aspectRatio, resolution, duration: totalDuration, refs: [] }, { ratio: aspectRatio, resolution, duration: totalDuration, refs: [] },
billingRates, billingRates,
); );
// 基础资产按图像挂牌预估;视频挂牌秒价。最终按成功任务实扣。 // 与实扣对齐:脚本(Seed 2.1 Pro 挂牌) + 默认出图挂牌×场次粗估 + 视频挂牌秒价×时长
const applyTeam = (raw: number) => {
const multiplier = billingRates.multiplier || 1;
if (!(raw > 0)) return 0;
return multiplier === 1 ? raw : Math.max(1, Math.round(Number((raw * multiplier).toFixed(6))));
};
const scriptUnit = (() => {
const texts = modelConfigs.filter((m) => m.capability === "text" && m.status === "active");
const script = texts.find((m) => m.name === QUICK_SCRIPT_MODEL) || texts.find((m) => m.is_default) || texts[0];
const listed = pointsPerCallFromCatalog(script);
return listed != null && listed > 0 ? listed : QUICK_SCRIPT_POINTS_FALLBACK;
})();
const imageUnit = (() => { const imageUnit = (() => {
const img = modelConfigs.find((m) => m.capability === "image" && m.status === "active") const images = modelConfigs.filter((m) => m.capability === "image" && m.status === "active");
// 与后端 get_default_model(IMAGE) 一致:优先 is_default,再回落影擎-Image2 名字
const img = images.find((m) => m.is_default)
|| images.find((m) => m.name === "gpt-image-2")
|| images[0]
|| modelConfigs.find((m) => m.capability === "image"); || modelConfigs.find((m) => m.capability === "image");
const listed = pointsPerImageFromCatalog(img); const listed = pointsPerImageFromCatalog(img);
return listed != null && listed > 0 ? listed : 20; return listed != null && listed > 0 ? listed : QUICK_IMAGE_POINTS_FALLBACK;
})(); })();
const estimatedPoints = videoEstimate.listed ? videoEstimate.points + sceneCount * imageUnit : 0; const estimatedPoints = videoEstimate.listed
? videoEstimate.points + applyTeam(scriptUnit) + applyTeam(sceneCount * imageUnit)
: 0;
const shellClass = [ const shellClass = [
"quick-create-shell", "quick-create-shell",
restoring ? "is-restoring" : "", restoring ? "is-restoring" : "",
+3 -2
View File
@@ -9,7 +9,8 @@ import {
UserRound, UserRound,
Users, Users,
Wallet, Wallet,
WandSparkles WandSparkles,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
export type Page = export type Page =
@@ -79,7 +80,7 @@ export type NavigateOptions = {
}; };
export type NavigateFn = (page: Page, options?: NavigateOptions) => void; export type NavigateFn = (page: Page, options?: NavigateOptions) => void;
export type Notice = { type: "success" | "error" | "info"; text: string } | null; export type Notice = { type: "success" | "error" | "info"; text: string } | null;
export type NavItem = { page: Page; label: string; icon: typeof Home; badge?: string }; export type NavItem = { page: Page; label: string; icon: LucideIcon; badge?: string };
// 仅团队主账号可访问的团队级页面。入口与路由守卫共用此规则,避免权限逻辑分散。 // 仅团队主账号可访问的团队级页面。入口与路由守卫共用此规则,避免权限逻辑分散。
const OWNER_ONLY_PAGES = new Set<Page>(["team", "account"]); const OWNER_ONLY_PAGES = new Set<Page>(["team", "account"]);
+1
View File
@@ -588,6 +588,7 @@ export type ModelConfig = {
display_name: string; display_name: string;
capability: string; capability: string;
status: string; status: string;
is_default?: boolean;
unit_price?: string; // 单位价(每张图/每次调用),前端据此算「预估扣费」与后端实扣一致(PMC#20) unit_price?: string; // 单位价(每张图/每次调用),前端据此算「预估扣费」与后端实扣一致(PMC#20)
// 模型元数据:自由创作视频模型带 pricing(元/百万tokens 分档价表)/resolutions/durations,前端预估消耗读它 // 模型元数据:自由创作视频模型带 pricing(元/百万tokens 分档价表)/resolutions/durations,前端预估消耗读它
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;