From ee3ddb49b407a891f1e52e9cbe01a25857789613 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Fri, 4 Sep 2026 17:51:16 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=86=E9=A2=91=E5=A4=8D=E5=88=BB=E7=A7=AF?= =?UTF-8?q?=E5=88=86=E8=A7=84=E5=88=99=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/free_video.py | 21 +++++++++- core/backend/apps/ai/test_video_digest.py | 17 ++++++++ core/backend/apps/ai/video_digest.py | 6 +-- core/backend/apps/ai/video_replace.py | 22 +++++++++- core/backend/apps/billing/pricing.py | 21 ++++++++-- .../src/components/free-create/constants.ts | 11 +++++ core/frontend/src/routes/video-remix.tsx | 15 +++++-- core/frontend/src/routes/video-replace.tsx | 40 +++++++++++++++---- 8 files changed, 132 insertions(+), 21 deletions(-) diff --git a/core/backend/apps/ai/free_video.py b/core/backend/apps/ai/free_video.py index 425059e..f1dfc06 100644 --- a/core/backend/apps/ai/free_video.py +++ b/core/backend/apps/ai/free_video.py @@ -646,6 +646,14 @@ def start_pending_free_video(task: AITask) -> AITask: team=task.team, ) reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule")) + try: + digest_points = Decimal(str(payload.get("digest_points") or 0)) + except Exception: + digest_points = Decimal("0") + if digest_points < 0: + digest_points = Decimal("0") + # 视频复刻·商品:拆镜 Gemini 挂牌已记在 digest_points,与 Seedance 一并预留 + reserve_amount = reserve_amount + digest_points with transaction.atomic(): locked = ( @@ -671,8 +679,10 @@ def start_pending_free_video(task: AITask) -> AITask: next_payload["estimated_tokens"] = tokens next_payload["review_pending"] = False next_payload.update(video_quote_payload(quote)) + # 保留拆镜挂牌,结算时加回 + next_payload["digest_points"] = str(digest_points) locked.request_payload = next_payload - locked.estimated_cost = quote.points + locked.estimated_cost = quote.points + digest_points locked.status = AITask.Status.RESERVED locked.save(update_fields=["request_payload", "estimated_cost", "status", "updated_at"]) @@ -992,10 +1002,17 @@ def finalize_free_video(*, task: AITask) -> AITask: except (TypeError, ValueError): total_tokens = 0 settle = settle_video_from_payload(actual_model, payload=payload, tokens=total_tokens) + try: + digest_points = Decimal(str(payload.get("digest_points") or 0)) + except Exception: + digest_points = Decimal("0") + if digest_points < 0: + digest_points = Decimal("0") if settle.meta.get("rule") == "missing_usage": + # estimated_cost 已含 digest_points actual, base_cost = locked.estimated_cost, locked.base_cost else: - actual, base_cost = settle.points, settle.base_cost_yuan + actual, base_cost = settle.points + digest_points, settle.base_cost_yuan if total_tokens > 0: payload["actual_tokens"] = total_tokens if settle.meta.get("rate"): diff --git a/core/backend/apps/ai/test_video_digest.py b/core/backend/apps/ai/test_video_digest.py index c9a8239..28f38b6 100644 --- a/core/backend/apps/ai/test_video_digest.py +++ b/core/backend/apps/ai/test_video_digest.py @@ -653,7 +653,24 @@ class VideoDigestBillingTests(TestCase): model_config_id=str(self.model.id), ) + def test_charges_hangpai_points_per_call_when_listed(self): + from decimal import Decimal + + from apps.billing.pricing import quote_video_digest + + # unit_price 仍是 10,但挂牌 100 → 扣 100 + self.model.metadata = { + **(self.model.metadata or {}), + "points_pricing": {"mode": "per_call", "points_per_call": 100}, + } + self.model.save(update_fields=["metadata"]) + self.assertEqual(quote_video_digest(model_config=self.model).points, Decimal("100")) + self.team.price_multiplier = Decimal("0.80") + self.assertEqual(quote_video_digest(team=self.team, model_config=self.model).points, Decimal("80")) + self.team.price_multiplier = Decimal("1") + def test_charges_thirty_points_not_model_unit_price(self): + from decimal import Decimal from apps.ai.models import AITask diff --git a/core/backend/apps/ai/video_digest.py b/core/backend/apps/ai/video_digest.py index ad555c6..0f51d77 100644 --- a/core/backend/apps/ai/video_digest.py +++ b/core/backend/apps/ai/video_digest.py @@ -1373,9 +1373,9 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict] """把一条已入库的参考视频拆成中文分镜稿,供「视频复刻·商品」当提示词骨架。 与「提炼提示词」页共用同一份 SKILL.md、同一个 Gemini 3.1 Pro 和同一套质检, - 保证两处产出一致;区别只在于这里不另建 VIDEO_DIGEST 任务、也不单独扣积分—— - 复刻本来就是一次收费,拆解是它的内部工序。模型调用的审计仍记在传入的复刻 - task 上(AIModelAttempt),出问题查得到。 + 保证两处产出一致;这里不另建 VIDEO_DIGEST 任务。拆解挂牌积分记在复刻任务的 + digest_points,与 Seedance 挂牌一并预留/结算。模型调用审计仍记在传入的复刻 + task 上(AIModelAttempt)。 """ import time diff --git a/core/backend/apps/ai/video_replace.py b/core/backend/apps/ai/video_replace.py index f016bf2..2624ef7 100644 --- a/core/backend/apps/ai/video_replace.py +++ b/core/backend/apps/ai/video_replace.py @@ -914,7 +914,7 @@ def start_replace_generation(task): def run_replace_digest(task) -> None: """Worker:参考视频 → 提炼页同款分镜稿 → 一次交给 Seedance。不传原片。 - 商品和角色共用。这一步失败 = 整条复刻失败。此时还没预留视频积分,不扣费。 + 商品和角色共用。这一步失败 = 整条复刻失败、不扣费。成功后由 start_pending_free_video 按「Gemini 挂牌 + Seedance 挂牌」一并预留。 """ from apps.ai.video_digest import VideoDigestError, digest_asset_video @@ -1576,6 +1576,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F references = params.get("references") or [] billing_duration = int(extra.get("billing_duration") or duration) billing_ratio = str(extra.get("billing_aspect_ratio") or aspect_ratio) + # 商品复刻:原片不进 Seedance 引用 → 按无视频引用秒价;角色复刻 references 含视频 → 含引用秒价 tokens, quote = quote_video_estimate( model_config, aspect_ratio=billing_ratio, @@ -1585,6 +1586,20 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F team=team, ) reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule")) + # 商品复刻另加 Gemini 提炼挂牌(points_per_call;未挂牌回落 30) + digest_points = Decimal("0") + digest_model_name = "" + if digest_pending: + from apps.ai.video_digest import resolve_digest_model_config + from apps.billing.pricing import quote_video_digest + + digest_model = resolve_digest_model_config() + if digest_model is None: + raise ValueError("视频提炼模型未配置,请联系管理员") + digest_quote = quote_video_digest(team=team, model_config=digest_model) + digest_points = Decimal(digest_quote.points) + digest_model_name = str(digest_model.name or "") + reserve_amount = reserve_amount + digest_points account = CreditAccount.objects.filter(team=team).first() available = (account.balance - account.reserved_balance) if account else Decimal("0") if available < reserve_amount: @@ -1615,6 +1630,9 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F "model_routing_v1": True, "review_pending": True, "digest_pending": digest_pending, + # 复刻内拆镜挂牌;start_pending_free_video / settle 会加回预留与实扣 + "digest_points": str(digest_points), + "digest_model": digest_model_name, } for key, value in extra.items(): if key in request_payload or value in (None, ""): @@ -1630,7 +1648,7 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F model_config=model_config, idempotency_key=f"free_video:{team.id}:{uuid.uuid4()}", request_payload=request_payload, - estimated_cost=quote.points, + estimated_cost=quote.points + digest_points, base_cost=Decimal("0"), ) if digest_pending: diff --git a/core/backend/apps/billing/pricing.py b/core/backend/apps/billing/pricing.py index 7f66c1f..3cbe5f4 100644 --- a/core/backend/apps/billing/pricing.py +++ b/core/backend/apps/billing/pricing.py @@ -137,9 +137,22 @@ def quote_flat(model_config, *, units: int = 1, team=None) -> Quote: def quote_video_digest(*, team=None, model_config=None) -> Quote: - """提炼提示词:固定 30 积分/次。失败退还。team 传入则乘团队价格系数。""" + """视频提炼提示词:优先 metadata.points_pricing.points_per_call 挂牌; + 不吃 unit_price(与旧「固定 30、忽略 unit_price」一致);未挂牌回落固定 30。 + 失败退还。team 传入则乘团队价格系数。""" + from apps.billing.points_rules import points_pricing_of, _int_points + multiplier = team_price_multiplier(team) - points = apply_team_price(VIDEO_DIGEST_POINTS, multiplier) + listed = None + if model_config is not None: + listed = _int_points(points_pricing_of(model_config).get("points_per_call")) + if listed is not None and listed > 0: + unit = Decimal(listed) + rule = "per_call" + else: + unit = VIDEO_DIGEST_POINTS + rule = "video_digest_flat" + points = apply_team_price(unit, multiplier) base_cost = Decimal("0") if model_config is not None: base_cost = Decimal(str(_pricing_meta(model_config).get("base_cost_yuan") or 0)) @@ -147,9 +160,9 @@ def quote_video_digest(*, team=None, model_config=None) -> Quote: points=points, base_cost_yuan=base_cost, meta={ - "rule": "video_digest_flat", + "rule": rule, "units": 1, - "unit_points": str(VIDEO_DIGEST_POINTS), + "unit_points": str(unit), "price_multiplier": str(multiplier), "rate": str(get_billing_config().points_per_yuan), }, diff --git a/core/frontend/src/components/free-create/constants.ts b/core/frontend/src/components/free-create/constants.ts index 152753e..d5787b1 100644 --- a/core/frontend/src/components/free-create/constants.ts +++ b/core/frontend/src/components/free-create/constants.ts @@ -78,6 +78,17 @@ export function pointsPerImageFromCatalog(config: ModelConfig | undefined): numb return Number.isFinite(unit) && unit > 0 ? unit : null; } +/** 文本/视觉次价:优先挂牌 points_per_call,否则 unit_price。 */ +export function pointsPerCallFromCatalog(config: ModelConfig | undefined): number | null { + const pricing = (config?.metadata?.points_pricing || {}) as Record; + if (pricing.points_per_call != null) { + const n = Number(pricing.points_per_call); + return Number.isFinite(n) ? n : null; + } + const unit = Number(config?.unit_price); + return Number.isFinite(unit) && unit > 0 ? unit : null; +} + export const MODE_LABELS: Record = { universal: "全能参考", keyframe: "首尾帧" }; // 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐) diff --git a/core/frontend/src/routes/video-remix.tsx b/core/frontend/src/routes/video-remix.tsx index a53e98e..c3d8324 100644 --- a/core/frontend/src/routes/video-remix.tsx +++ b/core/frontend/src/routes/video-remix.tsx @@ -29,7 +29,8 @@ import type { NavigateFn } from "./route-config"; export const REMIX_PROMPT_KEY = "fc-remix-prompt"; const REMIX_DRAFT_KEY = "vr-digest-draft"; const JOB_KEY = "airshelf:video-remix-job"; -const VIDEO_DIGEST_POINTS = 30; +/** 未挂牌时的回落价(与后端 VIDEO_DIGEST_POINTS 一致)。 */ +const VIDEO_DIGEST_POINTS_FALLBACK = 30; type ProgressStage = "upload" | "analyze" | "prompt"; @@ -190,9 +191,17 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: useEffect(() => { void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined); }, []); + // 与后端一致:只认挂牌 points_per_call,不吃 unit_price;未挂牌回落 30 + const hangpaiCall = (() => { + const raw = (activeModel?.metadata?.points_pricing as { points_per_call?: unknown } | undefined)?.points_per_call; + if (raw == null) return null; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : null; + })(); + const listedPoints = hangpaiCall ?? VIDEO_DIGEST_POINTS_FALLBACK; const estimatedPoints = priceMultiplier === 1 - ? VIDEO_DIGEST_POINTS - : Math.max(1, Math.round(Number((VIDEO_DIGEST_POINTS * priceMultiplier).toFixed(6)))); + ? listedPoints + : Math.max(1, Math.round(Number((listedPoints * priceMultiplier).toFixed(6)))); const loadHistory = async () => { try { diff --git a/core/frontend/src/routes/video-replace.tsx b/core/frontend/src/routes/video-replace.tsx index 3cd5ccb..a2b2db2 100644 --- a/core/frontend/src/routes/video-replace.tsx +++ b/core/frontend/src/routes/video-replace.tsx @@ -39,6 +39,9 @@ const CHARACTER_MARK = "[视频复刻·角色]"; const VIDEO_ACCEPT = "video/mp4,video/quicktime"; // 复刻固定 Seedance 2.5:参考视频只用来提炼分镜稿,成片最长 30 秒。 const REPLACE_SEEDANCE_MODEL = "doubao-seedance-2-5-260628"; +/** 与后端 DIGEST_VISION_MODEL_NAME 一致:商品复刻拆镜用 Gemini。 */ +const DIGEST_GEMINI_MODEL = "gemini-3.1-pro-preview"; +const DIGEST_POINTS_FALLBACK = 30; const PRODUCT_SOURCE_PURPOSE = "video_replace_product"; const REF_SECONDS_MAX = { product: 30, character: 30 } as const; const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024 } as const; @@ -399,6 +402,14 @@ export function VideoReplacePage({ () => videoConfigs.find((config) => config.name === REPLACE_SEEDANCE_MODEL) || videoConfigs[0], [videoConfigs], ); + const digestModel = useMemo(() => { + const texts = modelConfigs.filter((c) => c.capability === "text" && c.status === "active"); + return ( + texts.find((c) => c.name === DIGEST_GEMINI_MODEL) + || texts.find((c) => c.name.includes("gemini-3.1") || (c.display_name || "").includes("Gemini 3.1")) + || null + ); + }, [modelConfigs]); const copy = REPLACE_MODE_COPY[replaceMode]; const videoReady = Boolean(videoRef?.asset_id); @@ -430,18 +441,33 @@ export function VideoReplacePage({ })); const aspectRatio = ratioFromSize(videoMeta.width, videoMeta.height); const outputDuration = clampDuration(videoMeta.duration || SEEDANCE_MAX_OUTPUT_SECONDS); + // 上传前不显示积分;上传后 = Gemini 提炼挂牌(仅商品复刻) + Seedance 2.5 720P × 时长 + // 商品复刻:原片只提炼、不进 Seedance → 普通秒价;角色复刻:原片作引用视频 → 含引用秒价 + const digestHangpai = (() => { + if (replaceMode !== "product") return 0; + const pricing = (digestModel?.metadata?.points_pricing as { points_per_call?: unknown } | undefined)?.points_per_call; + if (pricing != null) { + const n = Number(pricing); + if (Number.isFinite(n) && n > 0) return n; + } + return DIGEST_POINTS_FALLBACK; + })(); + const imageRefCount = source === "library" + ? Math.max(1, libraryImageCount) + : Math.max(0, (tempFiles.length ? tempFiles : tempAssetRefs).length); + const seedanceRefs: { type: string }[] = [ + ...(replaceMode === "character" && videoReady ? [{ type: "video" as const }] : []), + ...Array.from({ length: imageRefCount }, () => ({ type: "image" as const })), + ]; const estimated = estimateCost(preferredModel, { ratio: aspectRatio, resolution: "720p", duration: outputDuration, - refs: [ - ...((source === "library" - ? Array.from({ length: Math.max(1, libraryImageCount) }, () => ({ type: "image" as const })) - : (tempFiles.length ? tempFiles : tempAssetRefs) - ).map(() => ({ type: "image" }))), - ], + refs: seedanceRefs, }, billingRates); - const points = estimated.points; + const points = videoReady + ? (estimated.points + (replaceMode === "product" ? digestHangpai : 0)) + : 0; // 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。 // 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。 const generating = Boolean(job && isInFlight(job.status)) || submitting;