视频复刻积分规则优化
This commit is contained in:
@@ -646,6 +646,14 @@ def start_pending_free_video(task: AITask) -> AITask:
|
|||||||
team=task.team,
|
team=task.team,
|
||||||
)
|
)
|
||||||
reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
|
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():
|
with transaction.atomic():
|
||||||
locked = (
|
locked = (
|
||||||
@@ -671,8 +679,10 @@ def start_pending_free_video(task: AITask) -> AITask:
|
|||||||
next_payload["estimated_tokens"] = tokens
|
next_payload["estimated_tokens"] = tokens
|
||||||
next_payload["review_pending"] = False
|
next_payload["review_pending"] = False
|
||||||
next_payload.update(video_quote_payload(quote))
|
next_payload.update(video_quote_payload(quote))
|
||||||
|
# 保留拆镜挂牌,结算时加回
|
||||||
|
next_payload["digest_points"] = str(digest_points)
|
||||||
locked.request_payload = next_payload
|
locked.request_payload = next_payload
|
||||||
locked.estimated_cost = quote.points
|
locked.estimated_cost = quote.points + digest_points
|
||||||
locked.status = AITask.Status.RESERVED
|
locked.status = AITask.Status.RESERVED
|
||||||
locked.save(update_fields=["request_payload", "estimated_cost", "status", "updated_at"])
|
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):
|
except (TypeError, ValueError):
|
||||||
total_tokens = 0
|
total_tokens = 0
|
||||||
settle = settle_video_from_payload(actual_model, payload=payload, tokens=total_tokens)
|
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":
|
if settle.meta.get("rule") == "missing_usage":
|
||||||
|
# estimated_cost 已含 digest_points
|
||||||
actual, base_cost = locked.estimated_cost, locked.base_cost
|
actual, base_cost = locked.estimated_cost, locked.base_cost
|
||||||
else:
|
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:
|
if total_tokens > 0:
|
||||||
payload["actual_tokens"] = total_tokens
|
payload["actual_tokens"] = total_tokens
|
||||||
if settle.meta.get("rate"):
|
if settle.meta.get("rate"):
|
||||||
|
|||||||
@@ -653,7 +653,24 @@ class VideoDigestBillingTests(TestCase):
|
|||||||
model_config_id=str(self.model.id),
|
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):
|
def test_charges_thirty_points_not_model_unit_price(self):
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from apps.ai.models import AITask
|
from apps.ai.models import AITask
|
||||||
|
|||||||
@@ -1373,9 +1373,9 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
|
|||||||
"""把一条已入库的参考视频拆成中文分镜稿,供「视频复刻·商品」当提示词骨架。
|
"""把一条已入库的参考视频拆成中文分镜稿,供「视频复刻·商品」当提示词骨架。
|
||||||
|
|
||||||
与「提炼提示词」页共用同一份 SKILL.md、同一个 Gemini 3.1 Pro 和同一套质检,
|
与「提炼提示词」页共用同一份 SKILL.md、同一个 Gemini 3.1 Pro 和同一套质检,
|
||||||
保证两处产出一致;区别只在于这里不另建 VIDEO_DIGEST 任务、也不单独扣积分——
|
保证两处产出一致;这里不另建 VIDEO_DIGEST 任务。拆解挂牌积分记在复刻任务的
|
||||||
复刻本来就是一次收费,拆解是它的内部工序。模型调用的审计仍记在传入的复刻
|
digest_points,与 Seedance 挂牌一并预留/结算。模型调用审计仍记在传入的复刻
|
||||||
task 上(AIModelAttempt),出问题查得到。
|
task 上(AIModelAttempt)。
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
|||||||
@@ -914,7 +914,7 @@ def start_replace_generation(task):
|
|||||||
def run_replace_digest(task) -> None:
|
def run_replace_digest(task) -> None:
|
||||||
"""Worker:参考视频 → 提炼页同款分镜稿 → 一次交给 Seedance。不传原片。
|
"""Worker:参考视频 → 提炼页同款分镜稿 → 一次交给 Seedance。不传原片。
|
||||||
|
|
||||||
商品和角色共用。这一步失败 = 整条复刻失败。此时还没预留视频积分,不扣费。
|
商品和角色共用。这一步失败 = 整条复刻失败、不扣费。成功后由 start_pending_free_video 按「Gemini 挂牌 + Seedance 挂牌」一并预留。
|
||||||
"""
|
"""
|
||||||
from apps.ai.video_digest import VideoDigestError, digest_asset_video
|
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 []
|
references = params.get("references") or []
|
||||||
billing_duration = int(extra.get("billing_duration") or duration)
|
billing_duration = int(extra.get("billing_duration") or duration)
|
||||||
billing_ratio = str(extra.get("billing_aspect_ratio") or aspect_ratio)
|
billing_ratio = str(extra.get("billing_aspect_ratio") or aspect_ratio)
|
||||||
|
# 商品复刻:原片不进 Seedance 引用 → 按无视频引用秒价;角色复刻 references 含视频 → 含引用秒价
|
||||||
tokens, quote = quote_video_estimate(
|
tokens, quote = quote_video_estimate(
|
||||||
model_config,
|
model_config,
|
||||||
aspect_ratio=billing_ratio,
|
aspect_ratio=billing_ratio,
|
||||||
@@ -1585,6 +1586,20 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F
|
|||||||
team=team,
|
team=team,
|
||||||
)
|
)
|
||||||
reserve_amount = video_reserve_amount(quote.points, rule=quote.meta.get("rule"))
|
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()
|
account = CreditAccount.objects.filter(team=team).first()
|
||||||
available = (account.balance - account.reserved_balance) if account else Decimal("0")
|
available = (account.balance - account.reserved_balance) if account else Decimal("0")
|
||||||
if available < reserve_amount:
|
if available < reserve_amount:
|
||||||
@@ -1615,6 +1630,9 @@ def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = F
|
|||||||
"model_routing_v1": True,
|
"model_routing_v1": True,
|
||||||
"review_pending": True,
|
"review_pending": True,
|
||||||
"digest_pending": digest_pending,
|
"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():
|
for key, value in extra.items():
|
||||||
if key in request_payload or value in (None, ""):
|
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,
|
model_config=model_config,
|
||||||
idempotency_key=f"free_video:{team.id}:{uuid.uuid4()}",
|
idempotency_key=f"free_video:{team.id}:{uuid.uuid4()}",
|
||||||
request_payload=request_payload,
|
request_payload=request_payload,
|
||||||
estimated_cost=quote.points,
|
estimated_cost=quote.points + digest_points,
|
||||||
base_cost=Decimal("0"),
|
base_cost=Decimal("0"),
|
||||||
)
|
)
|
||||||
if digest_pending:
|
if digest_pending:
|
||||||
|
|||||||
@@ -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:
|
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)
|
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")
|
base_cost = Decimal("0")
|
||||||
if model_config is not None:
|
if model_config is not None:
|
||||||
base_cost = Decimal(str(_pricing_meta(model_config).get("base_cost_yuan") or 0))
|
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,
|
points=points,
|
||||||
base_cost_yuan=base_cost,
|
base_cost_yuan=base_cost,
|
||||||
meta={
|
meta={
|
||||||
"rule": "video_digest_flat",
|
"rule": rule,
|
||||||
"units": 1,
|
"units": 1,
|
||||||
"unit_points": str(VIDEO_DIGEST_POINTS),
|
"unit_points": str(unit),
|
||||||
"price_multiplier": str(multiplier),
|
"price_multiplier": str(multiplier),
|
||||||
"rate": str(get_billing_config().points_per_yuan),
|
"rate": str(get_billing_config().points_per_yuan),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -78,6 +78,17 @@ export function pointsPerImageFromCatalog(config: ModelConfig | undefined): numb
|
|||||||
return Number.isFinite(unit) && unit > 0 ? unit : null;
|
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<string, unknown>;
|
||||||
|
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<FreeMode, string> = { universal: "全能参考", keyframe: "首尾帧" };
|
export const MODE_LABELS: Record<FreeMode, string> = { universal: "全能参考", keyframe: "首尾帧" };
|
||||||
|
|
||||||
// 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐)
|
// 上传素材限制(与后端 FreeVideoUploadView / jimeng inputBar 对齐)
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ import type { NavigateFn } from "./route-config";
|
|||||||
export const REMIX_PROMPT_KEY = "fc-remix-prompt";
|
export const REMIX_PROMPT_KEY = "fc-remix-prompt";
|
||||||
const REMIX_DRAFT_KEY = "vr-digest-draft";
|
const REMIX_DRAFT_KEY = "vr-digest-draft";
|
||||||
const JOB_KEY = "airshelf:video-remix-job";
|
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";
|
type ProgressStage = "upload" | "analyze" | "prompt";
|
||||||
|
|
||||||
@@ -190,9 +191,17 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
|
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
|
const estimatedPoints = priceMultiplier === 1
|
||||||
? VIDEO_DIGEST_POINTS
|
? listedPoints
|
||||||
: Math.max(1, Math.round(Number((VIDEO_DIGEST_POINTS * priceMultiplier).toFixed(6))));
|
: Math.max(1, Math.round(Number((listedPoints * priceMultiplier).toFixed(6))));
|
||||||
|
|
||||||
const loadHistory = async () => {
|
const loadHistory = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ const CHARACTER_MARK = "[视频复刻·角色]";
|
|||||||
const VIDEO_ACCEPT = "video/mp4,video/quicktime";
|
const VIDEO_ACCEPT = "video/mp4,video/quicktime";
|
||||||
// 复刻固定 Seedance 2.5:参考视频只用来提炼分镜稿,成片最长 30 秒。
|
// 复刻固定 Seedance 2.5:参考视频只用来提炼分镜稿,成片最长 30 秒。
|
||||||
const REPLACE_SEEDANCE_MODEL = "doubao-seedance-2-5-260628";
|
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 PRODUCT_SOURCE_PURPOSE = "video_replace_product";
|
||||||
const REF_SECONDS_MAX = { product: 30, character: 30 } as const;
|
const REF_SECONDS_MAX = { product: 30, character: 30 } as const;
|
||||||
const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024 } 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.find((config) => config.name === REPLACE_SEEDANCE_MODEL) || videoConfigs[0],
|
||||||
[videoConfigs],
|
[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 copy = REPLACE_MODE_COPY[replaceMode];
|
||||||
const videoReady = Boolean(videoRef?.asset_id);
|
const videoReady = Boolean(videoRef?.asset_id);
|
||||||
@@ -430,18 +441,33 @@ export function VideoReplacePage({
|
|||||||
}));
|
}));
|
||||||
const aspectRatio = ratioFromSize(videoMeta.width, videoMeta.height);
|
const aspectRatio = ratioFromSize(videoMeta.width, videoMeta.height);
|
||||||
const outputDuration = clampDuration(videoMeta.duration || SEEDANCE_MAX_OUTPUT_SECONDS);
|
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, {
|
const estimated = estimateCost(preferredModel, {
|
||||||
ratio: aspectRatio,
|
ratio: aspectRatio,
|
||||||
resolution: "720p",
|
resolution: "720p",
|
||||||
duration: outputDuration,
|
duration: outputDuration,
|
||||||
refs: [
|
refs: seedanceRefs,
|
||||||
...((source === "library"
|
|
||||||
? Array.from({ length: Math.max(1, libraryImageCount) }, () => ({ type: "image" as const }))
|
|
||||||
: (tempFiles.length ? tempFiles : tempAssetRefs)
|
|
||||||
).map(() => ({ type: "image" }))),
|
|
||||||
],
|
|
||||||
}, billingRates);
|
}, billingRates);
|
||||||
const points = estimated.points;
|
const points = videoReady
|
||||||
|
? (estimated.points + (replaceMode === "product" ? digestHangpai : 0))
|
||||||
|
: 0;
|
||||||
// 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。
|
// 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。
|
||||||
// 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。
|
// 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。
|
||||||
const generating = Boolean(job && isInFlight(job.status)) || submitting;
|
const generating = Boolean(job && isInFlight(job.status)) || submitting;
|
||||||
|
|||||||
Reference in New Issue
Block a user