Merge pull request 'fix: 修正视频线性计费与在线测试模型筛选' (#13) from codex/fix-linear-video-billing into main
ci / verify (push) Successful in 15m9s
release-ci / verify-tag (push) Successful in 16m46s
ci / verify (push) Successful in 15m9s
release-ci / verify-tag (push) Successful in 16m46s
This commit was merged in pull request #13.
This commit is contained in:
@@ -10,6 +10,8 @@ import (
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
const videoBillingUnitSeconds = 5
|
||||
|
||||
type EstimateResult struct {
|
||||
Items []any `json:"items"`
|
||||
Resolver string `json:"resolver"`
|
||||
@@ -135,7 +137,7 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
||||
baseKey = "videoBase"
|
||||
duration, durationSource := billingDurationSeconds(body, response)
|
||||
audioEnabled, audioSource := billingAudioEnabled(body, response)
|
||||
durationUnits := math.Max(1, math.Ceil(duration/5))
|
||||
durationUnits := videoDurationUnits(duration)
|
||||
amount := float64(count) *
|
||||
durationUnits *
|
||||
resourcePrice(config, resource, baseKey, "basePrice") *
|
||||
@@ -144,7 +146,7 @@ func (s *Service) billings(ctx context.Context, user *auth.User, kind string, bo
|
||||
resourceWeight(config, resource, "referenceVideoWeights", boolWeightKey(requestHasReferenceVideo(body))) *
|
||||
resourceWeight(config, resource, "voiceSpecifiedWeights", boolWeightKey(requestHasVoiceID(body, audioEnabled))) *
|
||||
discount
|
||||
return []any{billingLineWithDetails(candidate, resource, unit, count*int(durationUnits), roundPrice(amount), discount, simulated, map[string]any{
|
||||
return []any{billingLineWithDetails(candidate, resource, unit, videoDurationQuantity(duration, count), roundPrice(amount), discount, simulated, map[string]any{
|
||||
"count": count,
|
||||
"audio": audioEnabled,
|
||||
"audioSource": audioSource,
|
||||
@@ -416,6 +418,16 @@ func weightValueAliases(key string, name string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func videoDurationUnits(durationSeconds float64) float64 {
|
||||
return videoDurationQuantity(durationSeconds, 1)
|
||||
}
|
||||
|
||||
func videoDurationQuantity(durationSeconds float64, count int) float64 {
|
||||
const durationPrecision = 1_000_000_000
|
||||
scaledDuration := math.Round(durationSeconds * durationPrecision)
|
||||
return scaledDuration * float64(count) / (durationPrecision * videoBillingUnitSeconds)
|
||||
}
|
||||
|
||||
func requestOutputCount(body map[string]any) int {
|
||||
for _, key := range []string{"n", "count", "batch_size", "batchSize"} {
|
||||
if value := int(math.Ceil(floatFromAny(body[key]))); value > 0 {
|
||||
@@ -476,11 +488,7 @@ func generatedVideoDurationSeconds(result map[string]any) (float64, bool) {
|
||||
if duration <= 0 {
|
||||
continue
|
||||
}
|
||||
rounded := math.Round(duration)
|
||||
if rounded <= 0 {
|
||||
rounded = 1
|
||||
}
|
||||
return rounded, true
|
||||
return duration, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func TestImageBillingEstimateUsesCountResolutionAndQuality(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T) {
|
||||
func TestVideoBillingEstimateProratesFiveSecondUnitsAndDynamicWeights(t *testing.T) {
|
||||
service := &Service{}
|
||||
candidate := store.RuntimeModelCandidate{
|
||||
ModelName: "video-model",
|
||||
@@ -67,13 +67,13 @@ func TestVideoBillingEstimateUsesFiveSecondUnitsAndDynamicWeights(t *testing.T)
|
||||
}, candidate, clients.Response{}, true)
|
||||
|
||||
line := firstBillingLine(t, items)
|
||||
if got, want := floatFromAny(line["amount"]), 1620.0; got != want {
|
||||
if got, want := floatFromAny(line["amount"]), 1296.0; got != want {
|
||||
t.Fatalf("video estimated amount = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := floatFromAny(line["durationUnitCount"]), 3.0; got != want {
|
||||
if got, want := floatFromAny(line["durationUnitCount"]), 2.4; got != want {
|
||||
t.Fatalf("video duration units = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := line["quantity"], 3; got != want {
|
||||
if got, want := floatFromAny(line["quantity"]), 2.4; got != want {
|
||||
t.Fatalf("video quantity = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := line["durationSource"], "preprocessed_request"; got != want {
|
||||
@@ -172,13 +172,13 @@ func TestVideoBillingPrefersGeneratedDuration(t *testing.T) {
|
||||
}, false)
|
||||
|
||||
line := firstBillingLine(t, items)
|
||||
if got, want := floatFromAny(line["durationSeconds"]), 7.0; got != want {
|
||||
if got, want := floatFromAny(line["durationSeconds"]), 6.6; got != want {
|
||||
t.Fatalf("video generated duration = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := floatFromAny(line["durationUnitCount"]), 2.0; got != want {
|
||||
if got, want := floatFromAny(line["durationUnitCount"]), 1.32; got != want {
|
||||
t.Fatalf("video generated duration units = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := floatFromAny(line["amount"]), 200.0; got != want {
|
||||
if got, want := floatFromAny(line["amount"]), 132.0; got != want {
|
||||
t.Fatalf("video generated duration amount = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := line["durationSource"], "generated_video"; got != want {
|
||||
|
||||
@@ -184,6 +184,22 @@ func multiplyFixedAmountRatio(amount fixedAmount, numerator int, denominator int
|
||||
return fixedAmountFromBigInt(roundBigIntRatio(product, big.NewInt(int64(denominator))))
|
||||
}
|
||||
|
||||
func multiplyFixedProductRatio(base fixedAmount, integerFactors []int, fixedFactors []fixedAmount, denominator int) (fixedAmount, error) {
|
||||
if denominator == 0 {
|
||||
return 0, fmt.Errorf("division by zero")
|
||||
}
|
||||
product := big.NewInt(int64(base))
|
||||
for _, factor := range integerFactors {
|
||||
product.Mul(product, big.NewInt(int64(factor)))
|
||||
}
|
||||
divisor := big.NewInt(int64(denominator))
|
||||
for _, factor := range fixedFactors {
|
||||
product.Mul(product, big.NewInt(int64(factor)))
|
||||
divisor.Mul(divisor, big.NewInt(fixedScale))
|
||||
}
|
||||
return fixedAmountFromBigInt(roundBigIntRatio(product, divisor))
|
||||
}
|
||||
|
||||
func fixedAmountFromBigInt(value *big.Int) (fixedAmount, error) {
|
||||
if value == nil || !value.IsInt64() {
|
||||
return 0, errFixedAmountOverflow
|
||||
@@ -649,7 +665,11 @@ func (s *Service) billingsWithResolvedPricingV2(
|
||||
baseKey = "videoBase"
|
||||
duration, durationSource := billingDurationSeconds(body, response)
|
||||
audioEnabled, audioSource := billingAudioEnabled(body, response)
|
||||
durationUnits := int(math.Max(1, math.Ceil(duration/5)))
|
||||
durationUnits := videoDurationUnits(duration)
|
||||
durationFixed, durationErr := fixedAmountFromAny(duration)
|
||||
if durationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, pricing.calculationError(resource, durationErr)
|
||||
}
|
||||
price, priceErr := pricing.requiredPrice(resource, baseKey, "basePrice")
|
||||
if priceErr != nil {
|
||||
return nil, 0, resolvedPricing{}, priceErr
|
||||
@@ -670,11 +690,16 @@ func (s *Service) billingsWithResolvedPricingV2(
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
amount, calculationErr := pricing.calculate(resource, price, []int{count, durationUnits}, resolutionWeight, audioWeight, referenceVideoWeight, voiceWeight, discount)
|
||||
amount, calculationErr := multiplyFixedProductRatio(
|
||||
price,
|
||||
[]int{count},
|
||||
[]fixedAmount{durationFixed, resolutionWeight, audioWeight, referenceVideoWeight, voiceWeight, discount},
|
||||
videoBillingUnitSeconds,
|
||||
)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
return nil, 0, resolvedPricing{}, pricing.calculationError(resource, calculationErr)
|
||||
}
|
||||
item := buildLine(resource, unit, count*durationUnits, amount, map[string]any{
|
||||
item := buildLine(resource, unit, videoDurationQuantity(duration, count), amount, map[string]any{
|
||||
"count": count, "audio": audioEnabled, "audioSource": audioSource,
|
||||
"durationSeconds": duration, "durationSource": durationSource,
|
||||
"durationUnit": "5s", "durationUnitCount": durationUnits,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/clients"
|
||||
"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
|
||||
)
|
||||
|
||||
@@ -39,6 +41,9 @@ func TestFixedAmountOperationsRejectOverflow(t *testing.T) {
|
||||
if _, err := pricing.calculate("image", maximum, []int{2}); !isPricingUnavailable(err) {
|
||||
t.Fatalf("pricing overflow should be unavailable: %v", err)
|
||||
}
|
||||
if _, err := multiplyFixedProductRatio(maximum, []int{2}, nil, 1); !errors.Is(err, errFixedAmountOverflow) {
|
||||
t.Fatalf("product ratio overflow error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEstimatedOutputTokensUsesAliasesAndCapabilityFallback(t *testing.T) {
|
||||
@@ -138,6 +143,108 @@ func TestPricingWeightsUseFixedPrecisionAndRejectInvalidValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoBillingV2ProratesFiveSecondPriceByActualDuration(t *testing.T) {
|
||||
service := &Service{}
|
||||
candidate := store.RuntimeModelCandidate{ModelName: "video-model"}
|
||||
pricing := resolvedPricing{
|
||||
Config: map[string]any{
|
||||
"video": map[string]any{
|
||||
"basePrice": 100,
|
||||
"dynamicWeight": map[string]any{
|
||||
"audioWeights": map[string]any{"true": 2},
|
||||
},
|
||||
},
|
||||
},
|
||||
Currency: "resource",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
duration float64
|
||||
wantUnits float64
|
||||
wantAmount float64
|
||||
}{
|
||||
{name: "three seconds uses zero point six units", duration: 3, wantUnits: 0.6, wantAmount: 120},
|
||||
{name: "five seconds uses one unit", duration: 5, wantUnits: 1, wantAmount: 200},
|
||||
{name: "six seconds uses one point two units", duration: 6, wantUnits: 1.2, wantAmount: 240},
|
||||
{name: "fractional seconds retain fixed amount precision", duration: 6.000000001, wantUnits: 1.2000000002, wantAmount: 240.00000004},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
items, total, _, err := service.billingsWithResolvedPricingV2(
|
||||
context.Background(), nil, "videos.generations",
|
||||
map[string]any{"duration": test.duration, "audio": true},
|
||||
candidate, clients.Response{}, true, pricing,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("bill video: %v", err)
|
||||
}
|
||||
line := firstBillingLine(t, items)
|
||||
if got := total.Float64(); math.Abs(got-test.wantAmount) > 1e-9 {
|
||||
t.Fatalf("total amount=%v, want %v", got, test.wantAmount)
|
||||
}
|
||||
if got := floatFromAny(line["amount"]); math.Abs(got-test.wantAmount) > 1e-9 {
|
||||
t.Fatalf("line amount=%v, want %v", got, test.wantAmount)
|
||||
}
|
||||
if got := floatFromAny(line["quantity"]); math.Abs(got-test.wantUnits) > 1e-12 {
|
||||
t.Fatalf("quantity=%v, want %v", got, test.wantUnits)
|
||||
}
|
||||
if got := floatFromAny(line["durationUnitCount"]); math.Abs(got-test.wantUnits) > 1e-12 {
|
||||
t.Fatalf("duration units=%v, want %v", got, test.wantUnits)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoBillingV2RoundsOnlyAfterApplyingDurationCountAndWeights(t *testing.T) {
|
||||
service := &Service{}
|
||||
candidate := store.RuntimeModelCandidate{ModelName: "video-model"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
pricing resolvedPricing
|
||||
wantAmount string
|
||||
}{
|
||||
{
|
||||
name: "count preserves a sub-nano duration share",
|
||||
body: map[string]any{"duration": 1, "count": 5},
|
||||
pricing: resolvedPricing{Config: map[string]any{
|
||||
"video": map[string]any{"basePrice": "0.000000001"},
|
||||
}},
|
||||
wantAmount: "0.000000001",
|
||||
},
|
||||
{
|
||||
name: "weight does not amplify a rounded duration share",
|
||||
body: map[string]any{"duration": 3, "audio": true},
|
||||
pricing: resolvedPricing{Config: map[string]any{
|
||||
"video": map[string]any{
|
||||
"basePrice": "0.000000001",
|
||||
"dynamicWeight": map[string]any{
|
||||
"audioWeights": map[string]any{"true": 2},
|
||||
},
|
||||
},
|
||||
}},
|
||||
wantAmount: "0.000000001",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, total, _, err := service.billingsWithResolvedPricingV2(
|
||||
context.Background(), nil, "videos.generations", test.body,
|
||||
candidate, clients.Response{}, true, test.pricing,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("bill video: %v", err)
|
||||
}
|
||||
if got := total.String(); got != test.wantAmount {
|
||||
t.Fatalf("total amount=%s, want %s", got, test.wantAmount)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustFixedAmount(t *testing.T, value string) fixedAmount {
|
||||
t.Helper()
|
||||
amount, err := parseFixedAmount(value)
|
||||
|
||||
+4
-4
@@ -166,16 +166,16 @@ The response has this shape:
|
||||
"platformModelId": "<platform-model-id>",
|
||||
"resourceType": "video",
|
||||
"unit": "5s_video",
|
||||
"quantity": 3,
|
||||
"quantity": 2.4,
|
||||
"amount": 12.5,
|
||||
"currency": "resource",
|
||||
"discountFactor": 0.8,
|
||||
"simulated": true,
|
||||
"durationSeconds": 12,
|
||||
"durationUnitCount": 3
|
||||
"durationUnitCount": 2.4
|
||||
}
|
||||
],
|
||||
"resolver": "effective-pricing-v1",
|
||||
"resolver": "effective-pricing-v2",
|
||||
"totalAmount": 12.5,
|
||||
"currency": "resource"
|
||||
}
|
||||
@@ -197,7 +197,7 @@ Useful calculation checks:
|
||||
text input = input tokens / 1000 × input price × discount
|
||||
text output = output tokens / 1000 × output price × discount
|
||||
image = count × base price × quality/size/resolution weights × discount
|
||||
video = count × ceil(duration seconds / 5) × base price × applicable weights × discount
|
||||
video = count × (duration seconds / 5) × base price × applicable weights × discount
|
||||
speech = Unicode character count × audio price × discount
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
UPDATE model_pricing_rules
|
||||
SET formula_config = jsonb_set(
|
||||
COALESCE(formula_config, '{}'::jsonb),
|
||||
'{formula}',
|
||||
to_jsonb(replace(
|
||||
formula_config->>'formula',
|
||||
'ceil(duration_seconds / 5)',
|
||||
'(duration_seconds / 5)'
|
||||
)),
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE resource_type = 'video'
|
||||
AND strpos(formula_config->>'formula', 'ceil(duration_seconds / 5)') > 0;
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { PlatformModel } from '@easyai-ai-gateway/contracts';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterModelsForMode } from './PlaygroundPage';
|
||||
|
||||
function model(id: string, modelType: string[]) {
|
||||
return { id, modelType } as PlatformModel;
|
||||
}
|
||||
|
||||
function modelIds(models: PlatformModel[]) {
|
||||
return models.map((item) => item.id);
|
||||
}
|
||||
|
||||
describe('playground model filtering', () => {
|
||||
const models = [
|
||||
model('image-generation', ['image_generate']),
|
||||
model('legacy-image', ['image']),
|
||||
model('image-edit', ['image_edit']),
|
||||
model('image-to-video', ['video_generate', 'image_to_video']),
|
||||
model('text-to-video', ['text_to_video']),
|
||||
];
|
||||
|
||||
it('only shows image generation models when no reference image is present', () => {
|
||||
expect(modelIds(filterModelsForMode(models, 'image', false, 'text_to_video'))).toEqual([
|
||||
'image-generation',
|
||||
'legacy-image',
|
||||
]);
|
||||
});
|
||||
|
||||
it('only shows image editing models when a reference image is present', () => {
|
||||
expect(modelIds(filterModelsForMode(models, 'image', true, 'text_to_video'))).toEqual([
|
||||
'legacy-image',
|
||||
'image-edit',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not fall back to image-to-video models when no image model is available', () => {
|
||||
const videoOnlyModels = [
|
||||
model('kling-3-turbo', ['video_generate', 'image_to_video']),
|
||||
model('kling-1-5', ['image_to_video']),
|
||||
];
|
||||
|
||||
expect(filterModelsForMode(videoOnlyModels, 'image', false, 'text_to_video')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps image-to-video models available for the matching video mode', () => {
|
||||
expect(modelIds(filterModelsForMode(models, 'video', false, 'first_last_frame'))).toContain('image-to-video');
|
||||
});
|
||||
});
|
||||
@@ -810,7 +810,7 @@ function Composer(props: {
|
||||
<Select className="playgroundModelSelect" value={props.selectedModel ?? ''} disabled={!props.modelOptions.length} onChange={(event) => props.onModelChange(event.target.value)}>
|
||||
{props.modelOptions.length ? props.modelOptions.map((item) => (
|
||||
<option value={item.value} key={item.value}>{modelOptionLabel(item)}</option>
|
||||
)) : <option value="">模型选择</option>}
|
||||
)) : <option value="">{props.compact ? '模型选择' : '暂无可用模型'}</option>}
|
||||
</Select>
|
||||
{props.mode !== 'chat' && props.mediaSettings && props.onMediaSettingsChange && (
|
||||
<MediaSettingsPopover
|
||||
@@ -977,25 +977,25 @@ function mediaPromptPlaceholder(mode: PlaygroundMode) {
|
||||
return placeholderByMode.chat;
|
||||
}
|
||||
|
||||
function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasReference: boolean, videoMode: VideoCreateMode) {
|
||||
export function filterModelsForMode(models: PlatformModel[], mode: PlaygroundMode, hasReference: boolean, videoMode: VideoCreateMode) {
|
||||
if (mode === 'chat') {
|
||||
return filterWithFallback(models, ['text_generate', 'chat', 'responses', 'text']);
|
||||
return filterModelsByType(models, ['text_generate', 'chat', 'responses', 'text']);
|
||||
}
|
||||
if (mode === 'image') {
|
||||
const preferredTypes = hasReference ? ['image_edit', 'images.edits'] : ['image_generate', 'images.generations'];
|
||||
return filterWithFallback(models, [...preferredTypes, 'image']);
|
||||
return filterModelsByType(models, [...preferredTypes, 'image']);
|
||||
}
|
||||
const videoTypesByMode: Record<VideoCreateMode, string[]> = {
|
||||
first_last_frame: ['video_first_last_frame', 'image_to_video', 'video_generate'],
|
||||
omni_reference: ['omni_video', 'video_reference', 'video_generate'],
|
||||
text_to_video: ['text_to_video', 'video_generate'],
|
||||
};
|
||||
return filterWithFallback(models, [...videoTypesByMode[videoMode], 'video']);
|
||||
return filterModelsByType(models, [...videoTypesByMode[videoMode], 'video']);
|
||||
}
|
||||
|
||||
function filterWithFallback(models: PlatformModel[], modelTypes: string[]) {
|
||||
const exact = models.filter((model) => model.modelType.some((type) => modelTypes.includes(type)));
|
||||
return exact.length ? exact : models.filter((model) => modelTypes.some((type) => model.modelType.some((modelType) => modelType.includes(type) || type.includes(modelType))));
|
||||
function filterModelsByType(models: PlatformModel[], modelTypes: string[]) {
|
||||
const acceptedTypes = new Set(modelTypes);
|
||||
return models.filter((model) => model.modelType.some((type) => acceptedTypes.has(type)));
|
||||
}
|
||||
|
||||
function buildModelOptions(models: PlatformModel[]): ModelOption[] {
|
||||
|
||||
@@ -56,7 +56,7 @@ const modeDefinitions: ModeDefinition[] = [
|
||||
formula: '扣费 = 基础单价 × 生成时长单位 × 数量 × 分辨率、音频、参考视频、音色等计费参数。',
|
||||
match: (rule) => rule.resourceType === 'video',
|
||||
templates: (currency) => [
|
||||
createRule('video', '视频', 'video', '5s', 100, currency, 'duration_weight', 'count * ceil(duration_seconds / 5) * base_price * resolution_factor * audio_factor * reference_video_factor * voice_specified_factor', {
|
||||
createRule('video', '视频', 'video', '5s', 100, currency, 'duration_weight', 'count * (duration_seconds / 5) * base_price * resolution_factor * audio_factor * reference_video_factor * voice_specified_factor', {
|
||||
resolutionWeights: { '480p': 0.75, '720p': 1, '1080p': 1.5, '2160p': 2 },
|
||||
audioWeights: { true: 2, false: 1 },
|
||||
referenceVideoWeights: { true: 1.5, false: 1 },
|
||||
|
||||
+1
-1
@@ -1430,7 +1430,7 @@ effective price = rule price(request dimensions) * platform/model discount * use
|
||||
- 基础单位建议为 `5s` 或 `second`,与原 provider 配置保持可映射。
|
||||
- 动态权重至少支持:时长、分辨率、是否包含音频、是否使用参考视频、是否指定声音/音色、生成数量。
|
||||
- 分辨率示例:`480p`、`720p`、`1080p`、`2160p`。
|
||||
- 公式示例:`count * ceil(durationSeconds / unitSeconds) * basePrice * resolutionWeight * audioWeight * referenceWeight`。
|
||||
- 公式示例:`count * (durationSeconds / unitSeconds) * basePrice * resolutionWeight * audioWeight * referenceWeight`。
|
||||
- 规则维度:`durationSeconds`、`resolution`、`count`、`hasAudio`、`hasReferenceVideo`、`hasReferenceImage`、`voice`。
|
||||
|
||||
音频、音乐、数字人、3D 模型:
|
||||
|
||||
Reference in New Issue
Block a user