@@ -1,5 +1,7 @@
import { useEffect , useRef , type CSSProperties , type ReactNode } from 'react' ;
import type { GatewayTask , PlatformModel } from '@easyai-ai-gateway/contracts' ;
import Segmented from 'antd/es/segmented' ;
import Slider from 'antd/es/slider' ;
import {
Download ,
Edit3 ,
@@ -25,8 +27,10 @@ export interface MediaGenerationSettings {
aspectRatio : string ;
countPreset : MediaCountPreset ;
customCount : number ;
durationSeconds : number ;
height : number ;
outputMode : MediaOutputMode ;
outputAudio : boolean ;
resolution : MediaResolution ;
width : number ;
}
@@ -57,8 +61,12 @@ interface ResolutionOption {
export interface MediaModelCapabilities {
aspectRatios : string [ ] ;
durationOptions : number [ ] ;
durationRange : [ number , number ] ;
durationStep : number ;
maxCount : number ;
resolutions : MediaResolution [ ] ;
supportsAudio : boolean ;
supportsGroup : boolean ;
}
@@ -83,7 +91,7 @@ const aspectRatioOptions: AspectRatioOption[] = [
const resolutionOptions : ResolutionOption [ ] = [
{ value : '1K' , label : '标准 1K' , modes : [ 'image' ] } ,
{ value : '2K' , label : '高清 2K' , modes : [ 'image' ] } ,
{ value : '4K' , label : '超清 4K' , modes : [ 'image' , 'video' ] } ,
{ value : '4K' , label : '超清 4K' , modes : [ 'image' ] } ,
{ value : '480p' , label : '标清 480p' , modes : [ 'video' ] } ,
{ value : '720p' , label : '高清 720p' , modes : [ 'video' ] } ,
{ value : '1080p' , label : '全高清 1080p' , modes : [ 'video' ] } ,
@@ -103,8 +111,10 @@ export function defaultMediaGenerationSettings(): MediaGenerationSettings {
aspectRatio : '1:1' ,
countPreset : 1 ,
customCount : 6 ,
durationSeconds : 5 ,
height : 2048 ,
outputMode : 'single' ,
outputAudio : true ,
resolution : '2K' ,
width : 2048 ,
} ;
@@ -116,7 +126,18 @@ export function mediaOutputCount(settings: MediaGenerationSettings) {
return clampNumber ( raw , 1 , 20 ) ;
}
export function mediaRequestPayload ( settings : MediaGenerationSettings ) {
export function mediaRequestPayload ( settings : MediaGenerationSettings , mode : Exclude < PlaygroundMode , 'chat' > ) {
if ( mode === 'video' ) {
return {
aspect_ratio : settings.aspectRatio === 'auto' ? undefined : settings . aspectRatio ,
audio : settings.outputAudio ,
duration : settings.durationSeconds ,
duration_seconds : settings.durationSeconds ,
output_audio : settings.outputAudio ,
resolution : settings.resolution ,
} ;
}
const count = mediaOutputCount ( settings ) ;
const size = ` ${ settings . width } x ${ settings . height } ` ;
const highQuality = settings . resolution === '4K' || settings . resolution === '2160p' ;
@@ -136,14 +157,24 @@ export function deriveMediaModelCapabilities(
models : PlatformModel [ ] | PlatformModel | undefined ,
mode : Exclude < PlaygroundMode , 'chat' > ,
contextKey? : string ,
resolution? : string ,
) : MediaModelCapabilities {
const modelList = ( Array . isArray ( models ) ? models : models ? [ models ] : [ ] ) . filter ( Boolean ) ;
if ( ! modelList . length ) return defaultMediaModelCapabilities ( mode ) ;
const derived = modelList . map ( ( model ) = > deriveSingleMediaModelCapabilities ( model , mode , contextKey ) ) ;
const derived = modelList . map ( ( model ) = > deriveSingleMediaModelCapabilities ( model , mode , contextKey , resolution ) ) ;
if ( derived . length === 1 ) return derived [ 0 ] ;
const durationOptions = intersectNumberValues (
derived . map ( ( item ) = > durationValuesForCapabilities ( item ) ) ,
durationValuesForCapabilities ( defaultMediaModelCapabilities ( mode ) ) ,
) ;
return {
aspectRatios : intersectOptionValues ( derived . map ( ( item ) = > item . aspectRatios ) , aspectRatioOptions . map ( ( item ) = > item . value ) ) ,
durationOptions ,
durationRange : durationRangeFromValues ( durationOptions ) ,
durationStep : 1 ,
maxCount : Math.max ( 1 , Math . min ( . . . derived . map ( ( item ) = > item . maxCount ) ) ) ,
resolutions : intersectOptionValues ( derived . map ( ( item ) = > item . resolutions ) , resolutionOptionsForMode ( mode ) . map ( ( item ) = > item . value ) ) ,
supportsAudio : derived.every ( ( item ) = > item . supportsAudio ) ,
supportsGroup : derived.every ( ( item ) = > item . supportsGroup ) ,
} ;
}
@@ -155,6 +186,7 @@ export function normalizeMediaSettingsForCapabilities(
) {
const aspectOptions = filterAspectRatioOptions ( capabilities ) ;
const resolutionItems = filterResolutionOptions ( capabilities , mode ) ;
const durationOptions = durationValuesForCapabilities ( capabilities ) ;
const maxCount = Math . max ( 1 , Math . min ( capabilities . maxCount , 20 ) ) ;
const supportsGroup = capabilities . supportsGroup && maxCount > 1 ;
const next : MediaGenerationSettings = {
@@ -163,6 +195,15 @@ export function normalizeMediaSettingsForCapabilities(
resolution : resolutionItems.some ( ( item ) = > item . value === settings . resolution ) ? settings.resolution : resolutionItems [ 0 ] ? . value ? ? settings . resolution ,
} ;
if ( mode === 'video' ) {
next . countPreset = 1 ;
next . customCount = 1 ;
next . durationSeconds = closestDurationValue ( settings . durationSeconds , durationOptions ) ;
next . outputAudio = capabilities . supportsAudio ? settings.outputAudio : false ;
next . outputMode = 'single' ;
return mediaSettingsEqual ( settings , next ) ? settings : next ;
}
if ( ! supportsGroup ) {
next . countPreset = 1 ;
next . customCount = 1 ;
@@ -191,13 +232,21 @@ export function MediaSettingsPopover(props: {
} ) {
const capabilities = props . capabilities ? ? defaultMediaModelCapabilities ( props . mode ) ;
const aspectOptions = filterAspectRatioOptions ( capabilities ) ;
const resolutionItems = resolutionOptionsForMode ( props . mode ) ;
const enabledResolutions = new Set ( filterResolutionOptions ( capabilities , props . mode ) . map ( ( item ) = > item . value ) ) ;
const resolutionItems = filterResolutionOptions ( capabilities , props . mode ) ;
const isImageMode = props . mode === 'image' ;
const isVideoMode = props . mode === 'video' ;
const durationOptions = durationValuesForCapabilities ( capabilities ) ;
const selectedDuration = closestDurationValue ( props . settings . durationSeconds , durationOptions ) ;
const durationUsesExplicitOptions = capabilities . durationOptions . length > 0 ;
const durationMarks = durationSliderMarks ( durationOptions , durationUsesExplicitOptions ) ;
const durationMin = durationOptions [ 0 ] ? ? props . settings . durationSeconds ;
const durationMax = durationOptions [ durationOptions . length - 1 ] ? ? props . settings . durationSeconds ;
const durationStep = durationUsesExplicitOptions ? null : capabilities . durationStep ;
const maxCount = Math . max ( 1 , Math . min ( capabilities . maxCount , 20 ) ) ;
const supportsGroup = capabilities . supportsGroup && maxCount > 1 ;
const countOptions = countPresetOptions . filter ( ( item ) = > item . value === 'custom' ? maxCount > 4 : item.value <= Math . min ( 4 , maxCount ) ) ;
const count = mediaOutputCount ( props . settings ) ;
const unit = props . mode === 'video' ? '条' : '张' ;
const count = isImageMode ? mediaOutputCount ( props . settings ) : 1 ;
const unit = '张' ;
function patch ( next : Partial < MediaGenerationSettings > ) {
props . onChange ( normalizeMediaSettingsForCapabilities ( { . . . props . settings , . . . next } , capabilities , props . mode ) ) ;
@@ -247,8 +296,7 @@ export function MediaSettingsPopover(props: {
type = "button"
key = { item . value }
data-active = { props . settings . resolution === item . value }
disabled = { ! enabledResolutions . has ( item . value ) }
onClick = { ( ) = > enabledResolutions . has ( item . value ) && patch ( { resolution : item.value } ) }
onClick = { ( ) = > patch ( { resolution : item.value } ) }
>
{ item . label }
{ item . value === '4K' && < Sparkles size = { 15 } / > }
@@ -257,85 +305,130 @@ export function MediaSettingsPopover(props: {
< / div >
< / section >
< section className = "mediaSettingsSection" >
< span className = "mediaSettingsLabel" > 尺 寸 < / span >
< div className = "mediaSizeRow " >
< label >
< span > W < / span >
< Input
inputMode = "numeric "
min = { 128 }
size = "sm"
type = "number"
value = { props . settings . width }
onChange = { ( event ) = > patch ( { width : clampNumber ( Number ( event . target . value ) , 128 , 8192 ) } ) }
{ isVideoMode && (
< >
< section className = "mediaSettingsSection " >
< span className = "mediaSettingsLabel" > 同 时 生 成 声 音 < / span >
< Segmented
block
className = "mediaAudioSegment "
options = { [
{ label : '开启' , value : 'on' , disabled : ! capabilities . supportsAudio } ,
{ label : '关闭' , value : 'off' } ,
] }
value = { props . settings . outputAudio && capabilities . supportsAudio ? 'on' : 'off' }
onChange = { ( value ) = > patch ( { outputAudio : value === 'on' } ) }
/ >
< / label >
< Link2 size = { 18 } / >
< label >
< span > H < / span >
< Input
inputMode = "numeric"
min = { 128 }
size = "sm"
type = "number"
value = { props . settings . height }
onChange = { ( event ) = > patch ( { height : clampNumber ( Number ( event . target . value ) , 128 , 8192 ) } ) }
/ >
< / label >
< strong > PX < / strong >
< / div >
< / section >
{ ! capabilities . supportsAudio && (
< p className = "mediaUnsupportedNote" > 当 前 模 型 不 支 持 输 出 音 频 , 已 自 动 关 闭 。 < / p >
) }
< / section >
< section className = "mediaSettingsSection" >
< span className = "mediaSettingsLabel" > 生 成 数 量 < / span >
< div className = "mediaOutputModeSegment" >
< button type = "button" data-active = { props . settings . outputMode === 'single' } onClick = { ( ) = > patch ( { countPreset : 1 , outputMode : 'single' } ) } >
< ImageIcon size = { 15 } / >
单 图
< / button >
< button type = "button" disabled = { ! supportsGroup } data-active = { props . settings . outputMode === 'group' } onClick = { ( ) = > patch ( { countPreset : 2 , outputMode : 'group' } ) } >
< Images size = { 15 } / >
{ supportsGroup ? '组图' : '不支持组图' }
< / button >
< / div >
{ supportsGroup ? (
< >
< div className = "mediaCountGrid" >
{ countOptions . map ( ( item ) = > (
< button
type = "button"
key = { String ( item . value ) }
data-active = { props . settings . countPreset === item . value }
onClick = { ( ) = > selectCountPreset ( item . value ) }
>
{ item . value === 'custom' ? item . label : ` ${ item . label } ${ unit } ` }
< / button >
) ) }
< section className = "mediaSettingsSection" >
< div className = "mediaSettingsTitleRow" >
< span className = "mediaSettingsLabel" > 视 频 时 长 < / span >
< strong > { selectedDuration } s < / strong >
< / div >
{ props . settings . countPreset === 'custom' && props . settings . outputMode === 'group' && (
< label className = "mediaCustomCount" >
< span > 自 定 义 数 量 < / span >
< Slider
className = "mediaDurationSlider"
disabled = { durationOptions . length <= 1 }
marks = { durationMarks }
max = { durationMax }
min = { durationMin }
step = { durationStep }
tooltip = { { formatter : ( value ) = > ` ${ value ? ? selectedDuration } s ` } }
value = { selectedDuration }
onChange = { ( value ) = > {
if ( typeof value === 'number' ) patch ( { durationSeconds : value } ) ;
} }
/ >
< / section >
< / >
) }
{ isImageMode && (
< >
< section className = "mediaSettingsSection" >
< span className = "mediaSettingsLabel" > 尺 寸 < / span >
< div className = "mediaSizeRow" >
< label >
< span > W < / span >
< Input
inputMode = "numeric"
min = { 2 }
max = { maxCount }
min = { 128 }
size = "sm"
type = "number"
value = { props . settings . customCount }
onChange = { ( event ) = > patch ( { customCount : clampNumber ( Number ( event . target . value ) , 2 , maxCount ) } ) }
value = { props . settings . width }
onChange = { ( event ) = > patch ( { width : clampNumber ( Number ( event . target . value ) , 128 , 8192 ) } ) }
/ >
< / label >
< Link2 size = { 18 } / >
< label >
< span > H < / span >
< Input
inputMode = "numeric"
min = { 128 }
size = "sm"
type = "number"
value = { props . settings . height }
onChange = { ( event ) = > patch ( { height : clampNumber ( Number ( event . target . value ) , 128 , 8192 ) } ) }
/ >
< / label >
< strong > PX < / strong >
< / div >
< / section >
< section className = "mediaSettingsSection" >
< span className = "mediaSettingsLabel" > 生 成 数 量 < / span >
< div className = "mediaOutputModeSegment" >
< button type = "button" data-active = { props . settings . outputMode === 'single' } onClick = { ( ) = > patch ( { countPreset : 1 , outputMode : 'single' } ) } >
< ImageIcon size = { 15 } / >
单 图
< / button >
< button type = "button" disabled = { ! supportsGroup } data-active = { props . settings . outputMode === 'group' } onClick = { ( ) = > patch ( { countPreset : 2 , outputMode : 'group' } ) } >
< Images size = { 15 } / >
{ supportsGroup ? '组图' : '不支持组图' }
< / button >
< / div >
{ supportsGroup ? (
< >
< div className = "mediaCountGrid" >
{ countOptions . map ( ( item ) = > (
< button
type = "button"
key = { String ( item . value ) }
data-active = { props . settings . countPreset === item . value }
onClick = { ( ) = > selectCountPreset ( item . value ) }
>
{ item . value === 'custom' ? item . label : ` ${ item . label } ${ unit } ` }
< / button >
) ) }
< / div >
{ props . settings . countPreset === 'custom' && props . settings . outputMode === 'group' && (
< label className = "mediaCustomCount" >
< span > 自 定 义 数 量 < / span >
< Input
inputMode = "numeric"
min = { 2 }
max = { maxCount }
size = "sm"
type = "number"
value = { props . settings . customCount }
onChange = { ( event ) = > patch ( { customCount : clampNumber ( Number ( event . target . value ) , 2 , maxCount ) } ) }
/ >
< / label >
) }
< / >
) : (
< p className = "mediaUnsupportedNote" > 当 前 模 型 不 支 持 组 图 输 出 。 < / p >
) }
< / >
) : (
< p className = "mediaUnsupportedNote" > 当 前 模 型 不 支 持 组 图 输 出 。 < / p >
) }
< p className = "mediaSettingsHint" >
< Sparkles size = { 14 } / >
{ count } / { unit }
< / p >
< / section >
< p className = "mediaSettingsHint" >
< Sparkles size = { 14 } / >
{ count } / { unit }
< / p >
< / section >
< / >
) }
< / PopoverContent >
< / Popover >
) ;
@@ -515,11 +608,11 @@ function mediaResultItemFromEntry(entry: unknown, mode: Exclude<PlaygroundMode,
}
function mediaSettingsSummary ( settings : MediaGenerationSettings , mode : Exclude < PlaygroundMode , 'chat' > ) {
const count = mediaOutputCount ( settings ) ;
const unit = mode === 'video' ? '条' : '张' ;
const resolutionLabel = resolutionOptionsForMode ( mode ) . find ( ( item ) = > item . value === settings . resolution ) ? . label ? ? settings . resolution ;
if ( mode === 'video' ) return ` ${ settings . aspectRatio } | ${ resolutionLabel } | ${ settings . durationSeconds } s | ${ settings . outputAudio ? '有声音' : '无声音' } ` ;
const count = mediaOutputCount ( settings ) ;
const modeLabel = settings . outputMode === 'single' ? '单图' : '组图' ;
return ` ${ settings . aspectRatio } | ${ resolutionLabel } | ${ settings . width } x ${ settings . height } | ${ modeLabel } ${ count } ${ unit } ` ;
return ` ${ settings . aspectRatio } | ${ resolutionLabel } | ${ settings . width } x ${ settings . height } | ${ modeLabel } ${ count } 张 ` ;
}
function mediaStatusText ( run : MediaGenerationRun ) {
@@ -568,10 +661,12 @@ function deriveSingleMediaModelCapabilities(
model : PlatformModel ,
mode : Exclude < PlaygroundMode , 'chat' > ,
contextKey? : string ,
resolution? : string ,
) : MediaModelCapabilities {
const source = mergeCapabilityRecords ( model . capabilities , model . capabilityOverride ) ;
const typeKeys = capabilityTypeKeys ( model , source , mode , contextKey ) ;
const defaultCapabilities = defaultMediaModelCapabilities ( mode ) ;
const durationScopes = [ resolution , contextKey ] ;
const resolutionValues = normalizeResolutionValues ( stringListFromCapability ( firstCapabilityValue ( source , typeKeys , [ 'output_resolutions' ] ) , [ contextKey ] ) ) ;
const allowedAspectValues = normalizeAspectRatioValues ( stringListFromCapability ( firstCapabilityValue ( source , typeKeys , [ 'aspect_ratio_allowed' ] ) , [ contextKey ] ) ) ;
const ratioRange = ratioRangeFromValue ( firstCapabilityValue ( source , typeKeys , [ 'aspect_ratio_range' ] ) ) ;
@@ -583,23 +678,36 @@ function deriveSingleMediaModelCapabilities(
: allowedAspectValues . length ? allowedAspectValues : rangedAspectValues ;
const maxCountValue = numberFromUnknown ( firstCapabilityValue ( source , typeKeys , countCapabilityKeys ( mode ) ) ) ;
const explicitGroupSupport = boolFromUnknown ( firstCapabilityValue ( source , typeKeys , groupCapabilityKeys ( mode ) ) ) ;
const durationRange = durationRangeFromValue ( scopedCapabilityValue ( firstCapabilityValue ( source , typeKeys , [ 'duration_range' ] ) , durationScopes ) ) ? ? defaultCapabilities . durationRange ;
const durationStep = durationStepFromValue ( scopedCapabilityValue ( firstCapabilityValue ( source , typeKeys , [ 'duration_step' ] ) , durationScopes ) , defaultCapabilities . durationStep ) ;
const durationOptions = normalizeDurationValues ( numberListFromCapability ( scopedCapabilityValue ( firstCapabilityValue ( source , typeKeys , [ 'duration_options' ] ) , durationScopes ) ) ) ;
const explicitAudioSupport = boolFromUnknown ( firstCapabilityValue ( source , typeKeys , [ 'output_audio' ] ) ) ;
const maxCount = explicitGroupSupport === false ? 1 : clampNumber ( maxCountValue ? ? defaultCapabilities . maxCount , 1 , 20 ) ;
const supportsGroup = explicitGroupSupport === false ? false : maxCount > 1 ;
return {
aspectRatios : aspectRatios.length ? aspectRatios : defaultCapabilities.aspectRatios ,
durationOptions ,
durationRange ,
durationStep ,
maxCount ,
resolutions : resolutionValues.length ? resolutionValues : defaultCapabilities.resolutions ,
supportsAudio : explicitAudioSupport ? ? defaultCapabilities . supportsAudio ,
supportsGroup ,
} ;
}
function defaultMediaModelCapabilities ( mode : Exclude < PlaygroundMode , 'chat' > ) : MediaModelCapabilities {
const isVideo = mode === 'video' ;
return {
aspectRatios : aspectRatioOptions.map ( ( item ) = > item . value ) ,
durationOptions : [ ] ,
durationRange : isVideo ? [ 5 , 10 ] : [ 1 , 1 ] ,
durationStep : 1 ,
maxCount : 20 ,
resolutions : resolutionOptionsForMode ( mode ) . map ( ( item ) = > item . value ) ,
supportsGroup : true ,
supportsAudio : false ,
supportsGroup : mode === 'image' ,
} ;
}
@@ -616,6 +724,55 @@ function filterResolutionOptions(capabilities: MediaModelCapabilities, mode: Exc
return items . length ? items : modeOptions ;
}
function durationValuesForCapabilities ( capabilities : MediaModelCapabilities ) {
const [ min , max ] = capabilities . durationRange ;
const step = Math . max ( 1 , capabilities . durationStep ) ;
const options = capabilities . durationOptions . length
? capabilities.durationOptions
: expandDurationRange ( min , max , step ) ;
const filtered = options . filter ( ( value ) = > (
value >= min
&& value <= max
&& durationMatchesStep ( value , min , step )
) ) ;
return filtered . length ? filtered : [ min ] ;
}
function expandDurationRange ( min : number , max : number , step : number ) {
const values : number [ ] = [ ] ;
for ( let value = min ; value <= max ; value += step ) {
values . push ( value ) ;
}
if ( ! values . includes ( max ) ) values . push ( max ) ;
return normalizeDurationValues ( values ) ;
}
function durationMatchesStep ( value : number , min : number , step : number ) {
if ( step <= 1 ) return true ;
const ratio = ( value - min ) / step ;
return Math . abs ( ratio - Math . round ( ratio ) ) < 0.000001 ;
}
function closestDurationValue ( value : number , values : number [ ] ) {
if ( ! values . length ) return value ;
return values . reduce ( ( closest , item ) = > (
Math . abs ( item - value ) < Math . abs ( closest - value ) ? item : closest
) , values [ 0 ] ) ;
}
function durationSliderMarks ( values : number [ ] , showAll : boolean ) {
const markValues = showAll || values . length <= 16
? values
: uniqueNumberValues ( [ values [ 0 ] , values [ values . length - 1 ] ] ) ;
return Object . fromEntries ( markValues . map ( ( value ) = > [ value , ` ${ value } s ` ] ) ) ;
}
function durationRangeFromValues ( values : number [ ] ) : [ number , number ] {
const normalized = normalizeDurationValues ( values ) ;
if ( ! normalized . length ) return [ 5 , 10 ] ;
return [ normalized [ 0 ] , normalized [ normalized . length - 1 ] ] ;
}
function resolutionOptionsForMode ( mode : Exclude < PlaygroundMode , 'chat' > ) {
return resolutionOptions . filter ( ( item ) = > item . modes . includes ( mode ) ) ;
}
@@ -627,6 +784,13 @@ function intersectOptionValues(values: string[][], fallback: string[]) {
return intersection . length ? intersection : nonEmptyValues [ 0 ] ;
}
function intersectNumberValues ( values : number [ ] [ ] , fallback : number [ ] ) {
const nonEmptyValues = values . filter ( ( items ) = > items . length > 0 ) ;
if ( ! nonEmptyValues . length ) return fallback ;
const intersection = fallback . filter ( ( item ) = > nonEmptyValues . every ( ( items ) = > items . includes ( item ) ) ) ;
return intersection . length ? intersection : nonEmptyValues [ 0 ] ;
}
function capabilityTypeKeys (
model : PlatformModel ,
source : Record < string , unknown > ,
@@ -664,6 +828,23 @@ function nestedCapabilityValue(source: Record<string, unknown>, typeKeys: string
return undefined ;
}
function scopedCapabilityValue ( value : unknown , scopes : Array < string | undefined > ) : unknown {
const record = recordFromUnknown ( value ) ;
if ( ! record ) return value ;
const scopeKeys = uniqueStrings ( scopes . filter ( ( item ) : item is string = > Boolean ( item ) ) ) ;
for ( const scope of scopeKeys ) {
const scoped = record [ scope ] ;
if ( hasCapabilityValue ( scoped ) ) {
return scopedCapabilityValue ( scoped , scopeKeys . filter ( ( item ) = > item !== scope ) ) ;
}
}
for ( const scope of [ 'default' , 'all' , '*' ] ) {
const scoped = record [ scope ] ;
if ( hasCapabilityValue ( scoped ) ) return scoped ;
}
return value ;
}
function groupCapabilityKeys ( mode : Exclude < PlaygroundMode , 'chat' > ) {
return mode === 'image'
? [ 'output_multiple_images' , 'multiple_images' , 'support_multiple_images' , 'supports_group' ]
@@ -700,6 +881,15 @@ function stringListFromCapability(value: unknown, preferredKeys: Array<string |
return Object . values ( record ) . flatMap ( ( item ) = > stringListFromCapability ( item ) ) ;
}
function numberListFromCapability ( value : unknown ) : number [ ] {
if ( Array . isArray ( value ) ) return value . flatMap ( ( item ) = > numberListFromCapability ( item ) ) ;
if ( typeof value === 'number' ) return [ value ] ;
if ( typeof value === 'string' ) return value . match ( /-?\d+(?:\.\d+)?/g ) ? . map ( Number ) . filter ( Number . isFinite ) ? ? [ ] ;
const record = recordFromUnknown ( value ) ;
if ( ! record ) return [ ] ;
return Object . values ( record ) . flatMap ( ( item ) = > numberListFromCapability ( item ) ) ;
}
function normalizeAspectRatioValues ( values : string [ ] ) {
const allowedValues = new Set ( aspectRatioOptions . map ( ( item ) = > item . value ) ) ;
return uniqueStrings ( values . map ( ( value ) = > {
@@ -743,6 +933,14 @@ function ratioRangeFromValue(value: unknown): [number, number] | undefined {
return undefined ;
}
function durationRangeFromValue ( value : unknown ) : [ number , number ] | undefined {
const range = ratioRangeFromValue ( value ) ;
if ( ! range ) return undefined ;
const min = Math . max ( 1 , Math . round ( range [ 0 ] ) ) ;
const max = Math . max ( 1 , Math . round ( range [ 1 ] ) ) ;
return [ Math . min ( min , max ) , Math . max ( min , max ) ] ;
}
function aspectRatioWithinRange ( value : string , range : [ number , number ] ) {
const [ width , height ] = value . split ( ':' ) . map ( Number ) ;
if ( ! Number . isFinite ( width ) || ! Number . isFinite ( height ) || height <= 0 ) return false ;
@@ -771,6 +969,23 @@ function numberFromUnknown(value: unknown) {
return undefined ;
}
function durationStepFromValue ( value : unknown , fallback : number ) {
const parsed = numberFromUnknown ( value ) ;
if ( ! parsed || parsed <= 0 ) return fallback ;
return Math . max ( 1 , Math . round ( parsed ) ) ;
}
function normalizeDurationValues ( values : number [ ] ) {
return uniqueNumberValues ( values
. filter ( ( item ) = > Number . isFinite ( item ) && item > 0 )
. map ( ( item ) = > Math . round ( item ) ) )
. sort ( ( left , right ) = > left - right ) ;
}
function uniqueNumberValues ( values : number [ ] ) {
return Array . from ( new Set ( values ) ) ;
}
function uniqueStrings ( values : string [ ] ) {
return Array . from ( new Set ( values . map ( ( item ) = > item . trim ( ) ) . filter ( Boolean ) ) ) ;
}
@@ -779,8 +994,10 @@ function mediaSettingsEqual(left: MediaGenerationSettings, right: MediaGeneratio
return left . aspectRatio === right . aspectRatio
&& left . countPreset === right . countPreset
&& left . customCount === right . customCount
&& left . durationSeconds === right . durationSeconds
&& left . height === right . height
&& left . outputMode === right . outputMode
&& left . outputAudio === right . outputAudio
&& left . resolution === right . resolution
&& left . width === right . width ;
}