fix: normalize video duration steps
This commit is contained in:
parent
2685450f3e
commit
0d0d0b9115
@ -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 {
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user