Files
LTX-DeskWEB/frontend/lib/video-generation-model-specs.ts

316 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { components } from '../generated/backend-openapi'
export type VideoGenerationModelSpecsResponse = components['schemas']['GenerateVideoModelsSpecsResponse']
export type VideoGenerationModelSpecItem = components['schemas']['LTXVideoGenerationModelSpecItem']
export type VideoGenerationResolutionSpec = components['schemas']['LTXVideoGenerationResolutionSpec']
export type VideoGenerationOfferingCapabilities = NonNullable<
VideoGenerationModelSpecItem['spec']['capabilities']
>
export type VideoGenerationPipeline = components['schemas']['GenerateVideoRequest']['model']
export type VideoGenerationResolution = components['schemas']['GenerateVideoRequest']['resolution']
export type VideoGenerationDuration = Exclude<
components['schemas']['GenerateVideoRequest']['duration'],
null
>
export type VideoGenerationFps = components['schemas']['GenerateVideoRequest']['fps']
export type VideoGenerationAspectRatio = components['schemas']['GenerateVideoRequest']['aspectRatio']
export interface VideoGenerationSettingsShape {
model: string
duration: number | null
videoResolution: string
fps: number
aspectRatio?: string
audio?: boolean
}
export interface ResolvedVideoGenerationOptions {
modelOptions: VideoGenerationModelSpecItem[]
resolutionOptions: VideoGenerationResolution[]
fpsOptions: VideoGenerationFps[]
durationOptions: VideoGenerationDuration[]
selectedModel: VideoGenerationPipeline | null
selectedResolution: VideoGenerationResolution | null
selectedFps: VideoGenerationFps | null
selectedDuration: VideoGenerationDuration | null
autoDurationAvailable: boolean
hasCompatibleOptions: boolean
}
type DurationSelectionMode = 'preserve' | 'smallest_valid'
/** GenSpace picker floor. The API envelope includes 2–5s so gap fill can request shorts. */
export const GENSPACE_MIN_SELECTABLE_DURATION_S = 6
interface ResolveVideoGenerationOptionsParams<T extends VideoGenerationSettingsShape> {
settings: T
modelSpecs: VideoGenerationModelSpecItem[]
hasAudio?: boolean
minimumDuration?: number
durationSelection?: DurationSelectionMode
}
function getResolutionMap(
item: VideoGenerationModelSpecItem,
options: { hasAudio: boolean },
): Record<string, VideoGenerationResolutionSpec> {
const { hasAudio } = options
if (!hasAudio) {
return item.spec.supported_resolutions_durations
}
// A model with no a2v spec doesn't support audio-conditioned generation at all —
// must not fall back to the plain (non-a2v) matrix, or it looks compatible when it isn't.
return item.spec.a2v_supported_resolutions_durations ?? {}
}
function getResolutionEntries(
item: VideoGenerationModelSpecItem,
options: { hasAudio: boolean },
): Array<[VideoGenerationResolution, VideoGenerationResolutionSpec]> {
return Object.entries(getResolutionMap(item, options)).map(([resolution, spec]) => [
resolution as VideoGenerationResolution,
spec,
])
}
function getDurationsForFps(
resolutionSpec: VideoGenerationResolutionSpec,
fps: VideoGenerationFps,
): VideoGenerationDuration[] {
return (resolutionSpec.fps_to_durations[String(fps)] ?? []) as VideoGenerationDuration[]
}
function filterDurationsByMinimum(
durations: VideoGenerationDuration[],
minimumDuration: number | undefined,
): VideoGenerationDuration[] {
if (minimumDuration === undefined) return durations
return durations.filter((duration) => duration >= minimumDuration)
}
function getCompatibleFps(
resolutionSpec: VideoGenerationResolutionSpec,
options: { minimumDuration: number | undefined },
): VideoGenerationFps[] {
const { minimumDuration } = options
return Object.keys(resolutionSpec.fps_to_durations).map((fps) => Number(fps) as VideoGenerationFps).filter((fps) => (
filterDurationsByMinimum(getDurationsForFps(resolutionSpec, fps), minimumDuration).length > 0
))
}
function getCompatibleResolutionEntries(
item: VideoGenerationModelSpecItem,
options: { hasAudio: boolean; minimumDuration: number | undefined },
): Array<[VideoGenerationResolution, VideoGenerationResolutionSpec]> {
return getResolutionEntries(item, { hasAudio: options.hasAudio }).filter(([, resolutionSpec]) => (
getCompatibleFps(resolutionSpec, { minimumDuration: options.minimumDuration }).length > 0
))
}
function getCompatibleModelOptions(
modelSpecs: VideoGenerationModelSpecItem[],
options: { hasAudio: boolean; minimumDuration: number | undefined },
): VideoGenerationModelSpecItem[] {
const { hasAudio, minimumDuration } = options
// Always filter by resolution compatibility — hasAudio alone (independent of any
// minimumDuration constraint) can exclude a model, e.g. a fast-tier pipeline with no
// a2v spec. Skipping this whenever minimumDuration is unset used to let incompatible
// (audio-unsupported) models stay selectable and get stuck with no valid resolution.
return modelSpecs.filter((item) => (
getCompatibleResolutionEntries(item, { hasAudio, minimumDuration }).length > 0
))
}
function emptyResolvedOptions(
modelOptions: VideoGenerationModelSpecItem[],
extras: Partial<ResolvedVideoGenerationOptions> = {},
): ResolvedVideoGenerationOptions {
return {
modelOptions,
resolutionOptions: [],
fpsOptions: [],
durationOptions: [],
selectedModel: null,
selectedResolution: null,
selectedFps: null,
selectedDuration: null,
autoDurationAvailable: false,
hasCompatibleOptions: false,
...extras,
}
}
function chooseOption<T>(current: string | number | null, options: T[]): T | null {
return options.find((option) => option === current) ?? options[0] ?? null
}
export function getVideoGenerationModelSpecs(
specs: VideoGenerationModelSpecsResponse | null | undefined,
options: { useApiSpecs: boolean },
): VideoGenerationModelSpecItem[] {
const { useApiSpecs } = options
if (!specs) return []
return useApiSpecs ? specs.api_models : specs.local_models
}
export function getLocalOfferingCapabilities(
specs: VideoGenerationModelSpecsResponse | null | undefined,
): VideoGenerationOfferingCapabilities | null {
return specs?.local_models[0]?.spec.capabilities ?? null
}
export function getApiOfferingCapabilities(
specs: VideoGenerationModelSpecsResponse | null | undefined,
pipeline: string | null | undefined,
): VideoGenerationOfferingCapabilities | null {
if (!specs || !pipeline) return null
return specs.api_models.find((item) => item.pipeline === pipeline)?.spec.capabilities ?? null
}
export function resolveVideoGenerationOptions<T extends VideoGenerationSettingsShape>({
settings,
modelSpecs,
hasAudio = false,
minimumDuration,
durationSelection = 'preserve',
}: ResolveVideoGenerationOptionsParams<T>): ResolvedVideoGenerationOptions {
const modelOptions = getCompatibleModelOptions(modelSpecs, { hasAudio, minimumDuration })
const selectedModelItem = modelOptions.find((item) => item.pipeline === settings.model) ?? modelOptions[0] ?? null
if (!selectedModelItem) {
return emptyResolvedOptions(modelOptions)
}
const resolutionEntries = getCompatibleResolutionEntries(selectedModelItem, { hasAudio, minimumDuration })
const resolutionOptions = resolutionEntries.map(([resolution]) => resolution)
const selectedResolution = chooseOption(settings.videoResolution, resolutionOptions)
if (!selectedResolution) {
return emptyResolvedOptions(modelOptions, { selectedModel: selectedModelItem.pipeline, resolutionOptions })
}
const selectedResolutionSpec = resolutionEntries.find(([resolution]) => resolution === selectedResolution)?.[1] ?? null
if (!selectedResolutionSpec) {
return emptyResolvedOptions(modelOptions, {
selectedModel: selectedModelItem.pipeline,
resolutionOptions,
selectedResolution,
})
}
const fpsOptions = getCompatibleFps(selectedResolutionSpec, { minimumDuration })
const selectedFps = chooseOption(settings.fps, fpsOptions)
if (!selectedFps) {
return emptyResolvedOptions(modelOptions, {
selectedModel: selectedModelItem.pipeline,
resolutionOptions,
selectedResolution,
fpsOptions,
})
}
const durationOptions = filterDurationsByMinimum(
getDurationsForFps(selectedResolutionSpec, selectedFps),
minimumDuration,
)
const autoDurationAvailable = !hasAudio && Boolean(selectedModelItem.spec.capabilities?.auto_duration)
const selectedDuration = durationSelection === 'smallest_valid'
? durationOptions[0] ?? null
: autoDurationAvailable && settings.duration === null
? null
: chooseOption(settings.duration, durationOptions)
return {
modelOptions,
resolutionOptions,
fpsOptions,
durationOptions,
selectedModel: selectedModelItem.pipeline,
selectedResolution,
selectedFps,
selectedDuration,
autoDurationAvailable,
hasCompatibleOptions: selectedDuration !== null || autoDurationAvailable,
}
}
export function sanitizeVideoGenerationSettings<T extends VideoGenerationSettingsShape>(
settings: T,
modelSpecs: VideoGenerationModelSpecItem[],
options: {
hasAudio?: boolean
minimumDuration?: number
durationSelection?: DurationSelectionMode
} = {},
): T | null {
const resolved = resolveVideoGenerationOptions({
settings,
modelSpecs,
hasAudio: options.hasAudio,
minimumDuration: options.minimumDuration,
durationSelection: options.durationSelection,
})
if (
!resolved.hasCompatibleOptions
|| !resolved.selectedModel
|| !resolved.selectedResolution
|| !resolved.selectedFps
|| (resolved.selectedDuration === null && !resolved.autoDurationAvailable)
) {
return null
}
return {
...settings,
model: resolved.selectedModel,
videoResolution: resolved.selectedResolution,
fps: resolved.selectedFps,
duration: resolved.selectedDuration,
aspectRatio: (settings.aspectRatio === '9:16' ? '9:16' : '16:9') as VideoGenerationAspectRatio,
}
}
export function areVideoGenerationSettingsEquivalent<T extends VideoGenerationSettingsShape>(
left: T,
right: T,
): boolean {
return (
left.model === right.model
&& left.duration === right.duration
&& left.videoResolution === right.videoResolution
&& left.fps === right.fps
&& (left.aspectRatio ?? '16:9') === (right.aspectRatio ?? '16:9')
&& (left.audio ?? false) === (right.audio ?? false)
)
}
/**
* Fallback labels for persisted `generationParams.model` / picker pipeline ids.
*
* Prefer `resolvePipelineDisplayName` (backend spec) at generation time. Local ids like
* "fast"/"pro" are shared across LTX versions, so the fallback here stays version-agnostic —
* only API ids (`fast-2.5`, …) encode their version in the id itself.
*/
const PIPELINE_DISPLAY_NAMES: Record<string, string> = {
fast: 'LTX Fast',
pro: 'LTX Pro',
'fast-2.5': 'LTX-2.5 Fast',
'pro-2.5': 'LTX-2.5 Pro',
}
/** Returns a display label for a known video pipeline, or null if unknown/absent. */
export function formatPipelineDisplayName(model: string | undefined | null): string | null {
if (!model) return null
return PIPELINE_DISPLAY_NAMES[model] ?? null
}
/**
* Version-correct label for `pipeline` taken from the backend specs currently in effect.
* Returns null when the pipeline isn't in `modelSpecs`, so callers can fall back.
*/
export function resolvePipelineDisplayName(
modelSpecs: VideoGenerationModelSpecItem[],
pipeline: string | undefined | null,
): string | null {
if (!pipeline) return null
return modelSpecs.find((item) => item.pipeline === pipeline)?.spec.display_name ?? null
}