fix: normalize video duration steps

This commit is contained in:
wangbo 2026-05-13 12:29:00 +08:00
parent 2685450f3e
commit 0d0d0b9115
2 changed files with 137 additions and 15 deletions

View File

@ -3,6 +3,7 @@ package runner
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
@ -518,7 +519,7 @@ func (durationProcessor) Process(params map[string]any, modelType string, contex
resolution := firstNonEmptyString(stringFromAny(params["resolution"]), context.resolution)
modeKey := videoModeKey(params)
if options := scopedNumberList(capability["duration_options"], resolution, modeKey); len(options) > 0 {
normalized := closestNumber(duration, options)
normalized := nextAllowedNumber(duration, options)
params["duration"] = normalized
syncDurationSeconds(params)
if normalized != duration {
@ -528,7 +529,7 @@ func (durationProcessor) Process(params map[string]any, modelType string, contex
"duration",
duration,
normalized,
"duration 不在模型固定时长选项内,已调整为最近的允许值。",
"duration 不在模型固定时长选项内,已向上调整为允许值。",
capabilityPath(modelType, "duration_options"),
capability["duration_options"],
)
@ -555,6 +556,23 @@ func (durationProcessor) Process(params map[string]any, modelType string, contex
},
)
}
return true
}
step := durationStep(capability["duration_step"], resolution, modeKey)
normalized := normalizeDurationByStep(duration, step)
params["duration"] = normalized
syncDurationSeconds(params)
if normalized != duration {
context.recordChange(
"DurationProcessor",
"adjust",
"duration",
duration,
normalized,
"duration 不符合模型时长步进,已按步进向上归一。",
capabilityPath(modelType, "duration_step"),
capability["duration_step"],
)
}
return true
}
@ -1044,28 +1062,38 @@ func durationStep(value any, scopes ...string) float64 {
}
func normalizeDurationByRange(target float64, minValue float64, maxValue float64, step float64) float64 {
clamped := math.Min(math.Max(target, minValue), maxValue)
if step <= 0 {
return clamped
if minValue > maxValue {
minValue, maxValue = maxValue, minValue
}
snapped := math.Round((clamped-minValue)/step)*step + minValue
if step <= 0 {
step = 1
}
clamped := math.Min(math.Max(target, minValue), maxValue)
snapped := math.Ceil(((clamped-minValue)/step)-1e-9)*step + minValue
snapped = math.Min(math.Max(snapped, minValue), maxValue)
return math.Round(snapped*1_000_000) / 1_000_000
}
func closestNumber(target float64, values []float64) float64 {
func normalizeDurationByStep(target float64, step float64) float64 {
if step <= 0 {
step = 1
}
snapped := math.Ceil((target/step)-1e-9) * step
return math.Round(snapped*1_000_000) / 1_000_000
}
func nextAllowedNumber(target float64, values []float64) float64 {
if len(values) == 0 {
return target
}
closest := values[0]
minDiff := math.Abs(target - closest)
for _, value := range values[1:] {
diff := math.Abs(target - value)
if diff < minDiff {
minDiff = diff
closest = value
sorted := append([]float64(nil), values...)
sort.Float64s(sorted)
for _, value := range sorted {
if value >= target || math.Abs(value-target) < 1e-9 {
return value
}
}
return closest
return sorted[len(sorted)-1]
}
func videoModeKey(params map[string]any) string {

View File

@ -180,6 +180,100 @@ func TestParamProcessorVideoCapabilitiesNormalizeAndFilter(t *testing.T) {
}
}
func TestParamProcessorDurationRangeRoundsFractionalSecondsUp(t *testing.T) {
body := map[string]any{
"model": "Seedance",
"prompt": "animate it",
"duration": 5.5,
}
candidate := store.RuntimeModelCandidate{
ModelType: "video_generate",
Capabilities: map[string]any{
"video_generate": map[string]any{
"duration_range": []any{3, 12},
},
},
}
result := preprocessRequestWithLog("videos.generations", body, candidate)
if result.Body["duration"] != float64(6) && result.Body["duration"] != 6 {
t.Fatalf("fractional duration should be rounded up to default 1s step, got %+v", result.Body["duration"])
}
}
func TestParamProcessorDurationWithoutRangeStillRoundsUp(t *testing.T) {
body := map[string]any{
"model": "Seedance",
"prompt": "animate it",
"duration": 5.2,
}
candidate := store.RuntimeModelCandidate{
ModelType: "video_generate",
Capabilities: map[string]any{
"video_generate": map[string]any{},
},
}
result := preprocessRequestWithLog("videos.generations", body, candidate)
if result.Body["duration"] != float64(6) && result.Body["duration"] != 6 {
t.Fatalf("duration should default to a 1s upward step without range, got %+v", result.Body["duration"])
}
}
func TestParamProcessorDurationRangeUsesStepCeilingAndRange(t *testing.T) {
body := map[string]any{
"model": "Seedance",
"prompt": "animate it",
"duration": 6.1,
"duration_seconds": 6.1,
}
candidate := store.RuntimeModelCandidate{
ModelType: "image_to_video",
Capabilities: map[string]any{
"image_to_video": map[string]any{
"duration_range": []any{5, 10},
"duration_step": 2,
},
},
}
result := preprocessRequestWithLog("videos.generations", body, candidate)
if result.Body["duration"] != float64(7) && result.Body["duration"] != 7 {
t.Fatalf("duration should be rounded up by configured step, got %+v", result.Body["duration"])
}
if result.Body["duration_seconds"] != result.Body["duration"] {
t.Fatalf("duration_seconds should sync with normalized duration, got %+v", result.Body)
}
body["duration"] = 10.1
body["duration_seconds"] = 10.1
result = preprocessRequestWithLog("videos.generations", body, candidate)
if result.Body["duration"] != float64(10) && result.Body["duration"] != 10 {
t.Fatalf("duration should be capped by range max, got %+v", result.Body["duration"])
}
}
func TestParamProcessorDurationOptionsChooseNextAllowedValue(t *testing.T) {
body := map[string]any{
"model": "Seedance",
"prompt": "animate it",
"duration": 8.1,
}
candidate := store.RuntimeModelCandidate{
ModelType: "image_to_video",
Capabilities: map[string]any{
"image_to_video": map[string]any{
"duration_options": []any{4, 8, 12},
},
},
}
result := preprocessRequestWithLog("videos.generations", body, candidate)
if result.Body["duration"] != float64(12) && result.Body["duration"] != 12 {
t.Fatalf("duration should use next allowed option, got %+v", result.Body["duration"])
}
}
func TestParamProcessorVideoGenerateLogsFirstFrameRemoval(t *testing.T) {
body := map[string]any{
"model": "Seedance T2V",