一键成片优化
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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" : "",
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user