From 5ca570e8aff8e5b0bf71d9831ece87e7dd048c87 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 1 Jul 2026 19:13:34 -0400 Subject: [PATCH] populate model listing --- .../src/lib/loader/buildModelsFromListing.ts | 302 +++++++++++++----- 1 file changed, 223 insertions(+), 79 deletions(-) diff --git a/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts b/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts index 934b97a193..7ee14c6c94 100644 --- a/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts +++ b/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts @@ -1,16 +1,24 @@ // Builds the picker model lists FROM the backend omni listing (the Tauri -// command response), mirroring how the web apps render `res.models`. Membership -// and order come from the backend; the frontend overlay only supplies richer UI -// metadata (selectorName, badges, tags, size icons) when it has an entry for a -// model. Backend models with no overlay entry still appear via a minimal model -// built straight from the response, so NEW backend models show up immediately. +// command response). Membership + order come from the backend, and every +// capability the API expresses is mapped 1:1 — the static overlay lists +// (`IMAGE_MODELS` / `VIDEO_MODELS`) only contribute what the API does not +// return: +// - presentation (selectorName/Description/Badges, tags, progressBarTime) +// - page-subsetting flags (canTextToImage, canEditImages, canEditAngles, …) +// - desktop-native provider knowledge (`providers`) +// - models the backend registry doesn't know (switch_x, inpaint models, …) // -// Pure module (no tauri-api import). The store in `@storyteller/tauri-api` feeds -// it the command payload's `models[]`; the DTO is accepted structurally. +// API capability fields ALWAYS win over the overlay's copies, so the overlay's +// capability data is dead at runtime for backend-known models and can be +// deleted from the lists. +// +// Pure module (no tauri-api import). The store in `@storyteller/tauri-api` +// feeds it the command payload's `models[]`; the DTO is accepted structurally. import { ImageModel } from "../classes/ImageModel.js"; import { VideoModel } from "../classes/VideoModel.js"; import { ModelCreator } from "../classes/metadata/ModelCreator.js"; +import { SizeIconOption, SizeOption } from "../classes/metadata/SizeOption.js"; import { CommonAspectRatio } from "../classes/properties/CommonAspectRatio.js"; import { CommonResolution } from "../classes/properties/CommonResolution.js"; import { CommonQuality } from "../classes/properties/CommonQuality.js"; @@ -26,34 +34,41 @@ export interface ListingModelBase { model: string; model_creator?: string | null; full_name?: string | null; - is_disabled?: boolean | null; -} - -export interface ListingImageModel extends ListingModelBase { text_prompt_supported?: boolean | null; - image_refs_supported?: boolean | null; - image_refs_max?: number | null; + text_prompt_max_length?: number | null; + batch_size_max?: number | null; + batch_size_default?: number | null; + batch_size_options?: number[] | null; aspect_ratio_options?: string[] | null; aspect_ratio_default?: string | null; resolution_options?: string[] | null; resolution_default?: string | null; quality_options?: string[] | null; default_quality?: string | null; - batch_size_max?: number | null; - batch_size_default?: number | null; + is_disabled?: boolean | null; +} + +export interface ListingImageModel extends ListingModelBase { + image_refs_supported?: boolean | null; + image_refs_max?: number | null; } export interface ListingVideoModel extends ListingModelBase { + extra_info?: string | null; extra_info_short?: string | null; text_to_video_supported?: boolean | null; starting_keyframe_supported?: boolean | null; starting_keyframe_required?: boolean | null; ending_keyframe_supported?: boolean | null; - resolution_options?: string[] | null; - resolution_default?: string | null; + image_references_supported?: boolean | null; + image_references_max?: number | null; + video_references_max?: number | null; + video_references_max_total_duration_seconds?: number | null; + audio_references_max?: number | null; + audio_references_max_total_duration_seconds?: number | null; + show_generate_with_sound_toggle?: boolean | null; duration_seconds_options?: number[] | null; duration_seconds_default?: number | null; - show_generate_with_sound_toggle?: boolean | null; } // ── Public builders ──────────────────────────────────────────────────────── @@ -62,13 +77,13 @@ export const buildImageModelsFromListing = ( overlay: ImageModel[], listing: ListingImageModel[], ): ImageModel[] => - build(overlay, listing, BACKEND_TO_TAURI_IMAGE_ID, minimalImageModel); + build(overlay, listing, BACKEND_TO_TAURI_IMAGE_ID, mergedImageModel); export const buildVideoModelsFromListing = ( overlay: VideoModel[], listing: ListingVideoModel[], ): VideoModel[] => - build(overlay, listing, BACKEND_TO_TAURI_VIDEO_ID, minimalVideoModel); + build(overlay, listing, BACKEND_TO_TAURI_VIDEO_ID, mergedVideoModel); // ── Core assembly ────────────────────────────────────────────────────────── @@ -76,99 +91,167 @@ const build = ( overlay: T[], listing: L[], alias: Record, - makeMinimal: (m: L, tauriId: string) => T, + merge: (m: L, tauriId: string, overlayEntry: T | undefined) => T, ): T[] => { const overlayByTauriId = new Map(overlay.map((m) => [m.tauriId, m])); const knownTauriIds = new Set(listing.map((m) => alias[m.model] ?? m.model)); const result: T[] = []; const seenTauriIds = new Set(); - // Backend drives membership + order. + // Backend drives membership + order; every entry is rebuilt with API-first + // capabilities. for (const m of listing) { if (m.is_disabled === true) continue; const tauriId = alias[m.model] ?? m.model; - // Several backend ids can alias to one frontend model (e.g. the three - // Midjourney versions all map to the single "midjourney" entry) — only - // surface the first. + // Guard: several backend ids aliasing to one frontend model would render + // duplicates — only surface the first. if (seenTauriIds.has(tauriId)) continue; seenTauriIds.add(tauriId); - result.push(overlayByTauriId.get(tauriId) ?? makeMinimal(m, tauriId)); + result.push(merge(m, tauriId, overlayByTauriId.get(tauriId))); } // Append frontend-only models the backend has never heard of (switch_x, - // inpaint, angle models, …). + // inpaint models, …). for (const m of overlay) { if (!knownTauriIds.has(m.tauriId)) result.push(m); } return result; }; -// ── Minimal models (backend model with no overlay entry) ─────────────────── +// ── 1:1 merge constructors (API capability > overlay; presentation ← overlay) -const minimalImageModel = (m: ListingImageModel, tauriId: string): ImageModel => { - // Backend option strings share the CommonAspectRatio / CommonResolution / - // CommonQuality serde spellings; filter out any values this build doesn't - // know (forward-compat with newer servers). +const mergedImageModel = ( + m: ListingImageModel, + tauriId: string, + o: ImageModel | undefined, +): ImageModel => { const aspectRatios = knownValues(m.aspect_ratio_options, COMMON_ASPECT_RATIO_VALUES); const resolutions = knownValues(m.resolution_options, COMMON_RESOLUTION_VALUES); const qualityOptions = knownValues(m.quality_options, COMMON_QUALITY_VALUES); + const fullName = m.full_name ?? o?.fullName ?? m.model; return new ImageModel({ - id: tauriId, + // Identity. Keep the overlay's `id` (BY_ID lookups / history) when known. + id: o?.id ?? tauriId, tauriId, - fullName: displayName(m), + fullName, category: "image", - creator: creatorFor(m.model_creator, m.model), - selectorName: displayName(m), - selectorDescription: "", - selectorBadges: [], - maxGenerationCount: m.batch_size_max ?? 4, - defaultGenerationCount: m.batch_size_default ?? 1, - canTextToImage: m.text_prompt_supported !== false, - canUseImagePrompt: m.image_refs_supported === true, - maxImagePromptCount: m.image_refs_max ?? 1, - canChangeAspectRatio: aspectRatios.length > 0, - aspectRatios: aspectRatios as CommonAspectRatio[], - defaultAspectRatio: knownValue(m.aspect_ratio_default, COMMON_ASPECT_RATIO_VALUES) as - | CommonAspectRatio - | undefined, - canChangeResolution: resolutions.length > 0, - resolutions: resolutions as CommonResolution[], - defaultResolution: knownValue(m.resolution_default, COMMON_RESOLUTION_VALUES) as - | CommonResolution - | undefined, - qualityOptions: qualityOptions as CommonQuality[], - defaultQuality: knownValue(m.default_quality, COMMON_QUALITY_VALUES) as - | CommonQuality - | undefined, + creator: creatorFor(m.model_creator, m.model, o?.creator), + + // Presentation — the omni image API has no display fields yet. + selectorName: o?.selectorName ?? fullName, + selectorDescription: o?.selectorDescription ?? "", + selectorBadges: o?.selectorBadges ?? [], + tags: o?.tags ?? [], + progressBarTime: o?.progressBarTime, + + // Page-subsetting flags — frontend-owned concepts. + canTextToImage: o ? o.canTextToImage : m.text_prompt_supported !== false, + canEditImages: o?.canEditImages ?? false, + usesInpaintingMask: o?.usesInpaintingMask ?? false, + editingIsInpainting: o?.editingIsInpainting ?? false, + canEditAngles: o?.canEditAngles ?? false, + + // Desktop-native provider knowledge. + providers: o?.getProviders(), + + // Capabilities — served by the API, overlay only as a transitional + // fallback where the backend config leaves a field unset. + maxPromptLength: m.text_prompt_max_length ?? o?.maxPromptLength, + maxGenerationCount: m.batch_size_max ?? o?.maxGenerationCount ?? 4, + defaultGenerationCount: m.batch_size_default ?? o?.defaultGenerationCount ?? 1, + predefinedGenerationCounts: m.batch_size_options ?? o?.predefinedGenerationCounts, + canUseImagePrompt: m.image_refs_supported ?? o?.canUseImagePrompt ?? false, + maxImagePromptCount: m.image_refs_max ?? o?.maxImagePromptCount ?? 1, + canChangeAspectRatio: + aspectRatios.length > 0 || (o?.canChangeAspectRatio ?? false), + aspectRatios: + aspectRatios.length > 0 ? (aspectRatios as CommonAspectRatio[]) : o?.aspectRatios, + defaultAspectRatio: + (knownValue(m.aspect_ratio_default, COMMON_ASPECT_RATIO_VALUES) as + | CommonAspectRatio + | undefined) ?? o?.defaultAspectRatio, + canChangeResolution: + resolutions.length > 0 || (o?.canChangeResolution ?? false), + resolutions: + resolutions.length > 0 ? (resolutions as CommonResolution[]) : o?.resolutions, + defaultResolution: + (knownValue(m.resolution_default, COMMON_RESOLUTION_VALUES) as + | CommonResolution + | undefined) ?? o?.defaultResolution, + qualityOptions: + qualityOptions.length > 0 ? (qualityOptions as CommonQuality[]) : o?.qualityOptions, + defaultQuality: + (knownValue(m.default_quality, COMMON_QUALITY_VALUES) as + | CommonQuality + | undefined) ?? o?.defaultQuality, }); }; -const minimalVideoModel = (m: ListingVideoModel, tauriId: string): VideoModel => - new VideoModel({ - id: tauriId, +const mergedVideoModel = ( + m: ListingVideoModel, + tauriId: string, + o: VideoModel | undefined, +): VideoModel => { + const aspectRatios = knownValues(m.aspect_ratio_options, COMMON_ASPECT_RATIO_VALUES); + const fullName = m.full_name ?? o?.fullName ?? m.model; + + return new VideoModel({ + // Identity. Keep the overlay's `id` (BY_ID lookups / history) when known. + id: o?.id ?? tauriId, tauriId, - fullName: displayName(m), + fullName, category: "video", - creator: creatorFor(m.model_creator, m.model), - selectorName: displayName(m), - selectorDescription: m.extra_info_short ?? "", - selectorBadges: [], - startFrame: m.starting_keyframe_supported === true, - endFrame: m.ending_keyframe_supported === true, - requiresImage: - m.starting_keyframe_required === true || m.text_to_video_supported === false, - textToVideoSupported: m.text_to_video_supported !== false, - resolutionOptions: m.resolution_options ?? undefined, - defaultResolution: m.resolution_default ?? undefined, - durationOptions: m.duration_seconds_options ?? undefined, - defaultDuration: m.duration_seconds_default ?? undefined, - generateWithSound: m.show_generate_with_sound_toggle === true, + creator: creatorFor(m.model_creator, m.model, o?.creator), + + // Presentation. The video API DOES have descriptions (extra_info_short / + // extra_info) — prefer them so backend copy reaches the picker. + selectorName: o?.selectorName ?? fullName, + selectorDescription: + m.extra_info_short ?? m.extra_info ?? o?.selectorDescription ?? "", + selectorBadges: o?.selectorBadges ?? [], + tags: o?.tags ?? [], + progressBarTime: o?.progressBarTime, + supportsSystemPrompt: o?.supportsSystemPrompt, + + // Desktop-native provider knowledge. + providers: o?.getProviders(), + + // Capabilities — served by the API, overlay only as a transitional + // fallback where the backend config leaves a field unset. + maxPromptLength: m.text_prompt_max_length ?? o?.maxPromptLength, + startFrame: m.starting_keyframe_supported ?? o?.startFrame ?? false, + endFrame: m.ending_keyframe_supported ?? o?.endFrame ?? false, + requiresImage: m.starting_keyframe_required ?? o?.requiresImage ?? false, + textToVideoSupported: m.text_to_video_supported ?? o?.textToVideoSupported, + generateWithSound: m.show_generate_with_sound_toggle ?? o?.generateWithSound, + durationOptions: m.duration_seconds_options ?? o?.durationOptions, + defaultDuration: m.duration_seconds_default ?? o?.defaultDuration, + supportsReferenceMode: m.image_references_supported ?? o?.supportsReferenceMode, + maxReferenceImages: m.image_references_max ?? o?.maxReferenceImages, + maxReferenceVideos: m.video_references_max ?? o?.maxReferenceVideos, + maxVideoRefDuration: + m.video_references_max_total_duration_seconds ?? o?.maxVideoRefDuration, + maxReferenceAudios: m.audio_references_max ?? o?.maxReferenceAudios, + maxAudioRefDuration: + m.audio_references_max_total_duration_seconds ?? o?.maxAudioRefDuration, + resolutionOptions: + m.resolution_options?.map(resolutionLabel) ?? o?.resolutionOptions, + defaultResolution: m.resolution_default + ? resolutionLabel(m.resolution_default) + : o?.defaultResolution, + // Aspect handling: API aspect ratios drive the modern picker; legacy + // models (grok/sora native size UI) keep their overlay sizeOptions. + sizeOptions: + aspectRatios.length > 0 + ? aspectRatios.map(sizeOptionForAspectRatio) + : o?.sizeOptions, + supportsCommonAspectRatio: + aspectRatios.length > 0 || (o?.supportsCommonAspectRatio ?? false), }); +}; // ── Helpers ──────────────────────────────────────────────────────────────── -const displayName = (m: ListingModelBase): string => m.full_name || m.model; - const COMMON_ASPECT_RATIO_VALUES: Set = new Set(Object.values(CommonAspectRatio)); const COMMON_RESOLUTION_VALUES: Set = new Set(Object.values(CommonResolution)); const COMMON_QUALITY_VALUES: Set = new Set(Object.values(CommonQuality)); @@ -181,8 +264,67 @@ const knownValue = ( known: Set, ): string | undefined => (value != null && known.has(value) ? value : undefined); -// Guess a creator from the `model_creator` field, falling back to the model-id -// prefix, then to ArtCraft. +/** + * Derive a video size picker option from a CommonAspectRatio value. + * `tauriValue` is the CommonAspectRatio serde string sent in the request. + */ +const sizeOptionForAspectRatio = (value: string): SizeOption => { + switch (value) { + case "square": + return { tauriValue: value, textLabel: "1:1", icon: SizeIconOption.Square }; + case "square_hd": + return { tauriValue: value, textLabel: "1:1 HD", icon: SizeIconOption.Square }; + case "wide_sixteen_by_nine": + return { tauriValue: value, textLabel: "16:9", icon: SizeIconOption.Landscape16x9 }; + case "tall_nine_by_sixteen": + return { tauriValue: value, textLabel: "9:16", icon: SizeIconOption.Portrait9x16 }; + case "wide_four_by_three": + return { tauriValue: value, textLabel: "4:3", icon: SizeIconOption.Standard4x3 }; + case "tall_three_by_four": + return { tauriValue: value, textLabel: "3:4", icon: SizeIconOption.Portrait3x4 }; + case "wide_three_by_two": + return { tauriValue: value, textLabel: "3:2", icon: SizeIconOption.Landscape }; + case "tall_two_by_three": + return { tauriValue: value, textLabel: "2:3", icon: SizeIconOption.Portrait }; + case "wide_five_by_four": + return { tauriValue: value, textLabel: "5:4", icon: SizeIconOption.Landscape }; + case "tall_four_by_five": + return { tauriValue: value, textLabel: "4:5", icon: SizeIconOption.Portrait }; + case "wide_twenty_one_by_nine": + return { tauriValue: value, textLabel: "21:9", icon: SizeIconOption.Landscape16x9 }; + case "tall_nine_by_twenty_one": + return { tauriValue: value, textLabel: "9:21", icon: SizeIconOption.Portrait9x16 }; + case "wide": + return { tauriValue: value, textLabel: "Landscape", icon: SizeIconOption.Landscape }; + case "tall": + return { tauriValue: value, textLabel: "Portrait", icon: SizeIconOption.Portrait }; + case "auto": + return { tauriValue: value, textLabel: "Auto", icon: SizeIconOption.Square }; + default: + return { tauriValue: value, textLabel: value, icon: SizeIconOption.Square }; + } +}; + +/** + * CommonResolution serde value -> the UI label form the video promptbox uses. + * (PromptBoxVideo's RESOLUTION_STRING_TO_COMMON accepts both forms; labels + * display better.) + */ +const resolutionLabel = (value: string): string => { + switch (value) { + case "four_eighty_p": + return "480p"; + case "seven_twenty_p": + return "720p"; + case "ten_eighty_p": + return "1080p"; + default: + return value; // Raw enum value; still request-mappable. + } +}; + +// Guess a creator from the API's `model_creator`, then the overlay, then the +// model-id prefix, then ArtCraft. const CREATOR_BY_PREFIX: Array<[string, ModelCreator]> = [ ["flux", ModelCreator.BlackForestLabs], ["nano_banana", ModelCreator.Google], @@ -201,9 +343,11 @@ const CREATOR_BY_PREFIX: Array<[string, ModelCreator]> = [ const creatorFor = ( raw: string | null | undefined, modelId: string, + overlayCreator: ModelCreator | undefined, ): ModelCreator => { const mapped = modelCreatorFromBackend(raw ?? undefined); if (mapped) return mapped; + if (overlayCreator) return overlayCreator; for (const [prefix, creator] of CREATOR_BY_PREFIX) { if (modelId.startsWith(prefix)) return creator; }