diff --git a/apps/api/internal/runner/param_processor.go b/apps/api/internal/runner/param_processor.go index 26cdfcd..621a574 100644 --- a/apps/api/internal/runner/param_processor.go +++ b/apps/api/internal/runner/param_processor.go @@ -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 { diff --git a/apps/api/internal/runner/param_processor_test.go b/apps/api/internal/runner/param_processor_test.go index 1804ea3..34ab9f8 100644 --- a/apps/api/internal/runner/param_processor_test.go +++ b/apps/api/internal/runner/param_processor_test.go @@ -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",