From 6ff95deab4f3d390e8a93e6349080f533d63d544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?math=2E=CE=BC=20=E2=9C=AF?= <96264224+matthewpenkala@users.noreply.github.com> Date: Sun, 9 Aug 2026 04:26:32 -0600 Subject: [PATCH 1/2] Honor catalog video aspect-ratio defaults --- .../src/lib/useVideoCostEstimate.ts | 14 ++- .../promptbox/src/lib/PromptBoxVideo.tsx | 67 ++++++++--- frontend/libs/model-list/src/index.ts | 5 + .../model-list/src/lib/classes/VideoModel.ts | 6 + .../properties/VideoAspectRatio.spec.ts | 112 ++++++++++++++++++ .../classes/properties/VideoAspectRatio.ts | 39 ++++++ .../src/lib/loader/buildModelsFromListing.ts | 4 + 7 files changed, 226 insertions(+), 21 deletions(-) create mode 100644 frontend/libs/model-list/src/lib/classes/properties/VideoAspectRatio.spec.ts create mode 100644 frontend/libs/model-list/src/lib/classes/properties/VideoAspectRatio.ts diff --git a/frontend/libs/components/pricing-modal/src/lib/useVideoCostEstimate.ts b/frontend/libs/components/pricing-modal/src/lib/useVideoCostEstimate.ts index 82335bcf1c..07cc483e94 100644 --- a/frontend/libs/components/pricing-modal/src/lib/useVideoCostEstimate.ts +++ b/frontend/libs/components/pricing-modal/src/lib/useVideoCostEstimate.ts @@ -1,6 +1,10 @@ import { useEffect, useState } from "react"; import { ModelPage } from "@storyteller/ui-model-selector"; -import { Model, VideoModel } from "@storyteller/model-list"; +import { + Model, + resolveVideoAspectRatioOption, + VideoModel, +} from "@storyteller/model-list"; import { GenerationProvider } from "@storyteller/api-enums"; import { usePromptVideoStore } from "@storyteller/ui-promptbox"; import { @@ -45,8 +49,12 @@ export function useVideoCostEstimate( } const videoModel = selectedModel as VideoModel; - const commonAspectRatio = videoAspectRatioToCommonAspectRatio( + const resolvedAspectRatioOption = resolveVideoAspectRatioOption( + videoModel, aspectRatio, + ); + const commonAspectRatio = videoAspectRatioToCommonAspectRatio( + resolvedAspectRatioOption?.textLabel ?? null, videoModel.sizeOptions, ); const commonResolution = stringToCommonVideoResolution(resolution); @@ -88,7 +96,7 @@ export function useVideoCostEstimate( }); }, [ activePage, - selectedModel?.id, + selectedModel, selectedProvider, duration, aspectRatio, diff --git a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx index 6415b79e1e..cd09d00083 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx @@ -12,6 +12,8 @@ import { arrayMove } from "@dnd-kit/sortable"; import { CommonResolution, effectivePromptMaxLength, + resolveVideoAspectRatio, + resolveVideoAspectRatioOption, SizeIconOption, SizeOption, VideoModel, @@ -257,22 +259,27 @@ export const PromptBoxVideo = ({ // TODO: Get rid of default resolutions. Just disable it if not present. let aspectRatioOptions: PopoverItem[]; - const buildAspectRatioOptions = (options: SizeOption[]): PopoverItem[] => { - const currentExists = options.some( - (option) => option.textLabel === aspectRatio, + const buildAspectRatioOptions = ( + options: SizeOption[], + defaultAspectRatio?: string, + ): PopoverItem[] => { + const resolvedAspectRatio = resolveVideoAspectRatio( + { sizeOptions: options, defaultAspectRatio }, + aspectRatio, ); - const useFirstOption = !currentExists; - return options.map((option, index) => ({ + return options.map((option) => ({ label: option.textLabel, - selected: - option.textLabel === aspectRatio || (useFirstOption && index === 0), + selected: option.textLabel === resolvedAspectRatio, icon: , })); }; if (!!selectedModel?.sizeOptions && selectedModel.sizeOptions.length > 0) { - aspectRatioOptions = buildAspectRatioOptions(selectedModel.sizeOptions); + aspectRatioOptions = buildAspectRatioOptions( + selectedModel.sizeOptions, + selectedModel.defaultAspectRatio, + ); } else { aspectRatioOptions = buildAspectRatioOptions(DEFAULT_RESOLUTIONS); } @@ -390,6 +397,24 @@ export const PromptBoxVideo = ({ } }, [selectedModel]); + // The catalog's declared default can differ from the first advertised + // option. Resolve synchronously elsewhere as well, but keep the store in + // sync so every consumer displays the same legal selection. + useEffect(() => { + if (!selectedModel?.sizeOptions.length) return; + const currentAspectRatio = usePromptVideoStore.getState().aspectRatio; + const resolvedAspectRatio = resolveVideoAspectRatio( + selectedModel, + currentAspectRatio, + ); + if ( + resolvedAspectRatio !== null && + resolvedAspectRatio !== currentAspectRatio + ) { + setAspectRatio(resolvedAspectRatio); + } + }, [selectedModel, setAspectRatio]); + // Reset input mode when switching to a model that doesn't support reference. // Read from store directly to avoid stale closure (same as duration above). useEffect(() => { @@ -1173,19 +1198,14 @@ export const PromptBoxVideo = ({ } if (selectedModel.supportsCommonAspectRatio) { - const selectedOption = selectedModel.sizeOptions?.find( - (option) => option.textLabel === aspectRatio, + const selectedOption = resolveVideoAspectRatioOption( + selectedModel, + aspectRatio, ); if (selectedOption) { request.aspect_ratio = selectedOption.tauriValue as typeof request.aspect_ratio; - } else { - const maybeDefault = selectedModel.sizeOptions[0]; - if (!!maybeDefault) { - request.aspect_ratio = - maybeDefault.tauriValue as typeof request.aspect_ratio; - } } } @@ -1223,8 +1243,19 @@ export const PromptBoxVideo = ({ }; const getCurrentAspectRatioIcon = (): SizeIconOption => { - const allOptions = selectedModel?.sizeOptions ?? DEFAULT_RESOLUTIONS; - const match = allOptions.find((o) => o.textLabel === aspectRatio); + const allOptions = selectedModel?.sizeOptions?.length + ? selectedModel.sizeOptions + : DEFAULT_RESOLUTIONS; + const resolvedAspectRatio = resolveVideoAspectRatio( + { + sizeOptions: allOptions, + defaultAspectRatio: selectedModel?.defaultAspectRatio, + }, + aspectRatio, + ); + const match = allOptions.find( + (option) => option.textLabel === resolvedAspectRatio, + ); return match?.icon ?? SizeIconOption.Landscape; }; diff --git a/frontend/libs/model-list/src/index.ts b/frontend/libs/model-list/src/index.ts index d78cce26d7..66fe227298 100644 --- a/frontend/libs/model-list/src/index.ts +++ b/frontend/libs/model-list/src/index.ts @@ -4,6 +4,11 @@ export * from "./lib/classes/VideoModel.js"; export * from "./lib/classes/properties/CommonAspectRatio.js"; export * from "./lib/classes/properties/CommonResolution.js"; export * from "./lib/classes/properties/CommonQuality.js"; +export { + resolveVideoAspectRatio, + resolveVideoAspectRatioOption, + type VideoAspectRatioCapabilities, +} from "./lib/classes/properties/VideoAspectRatio.js"; export * from "./lib/classes/metadata/ModelCreator.js"; export * from "./lib/classes/metadata/ModelCreatorIcons.js"; export * from "./lib/classes/metadata/ModelCreatorIconForId.js"; diff --git a/frontend/libs/model-list/src/lib/classes/VideoModel.ts b/frontend/libs/model-list/src/lib/classes/VideoModel.ts index 61132e3ce8..b4668c2bea 100644 --- a/frontend/libs/model-list/src/lib/classes/VideoModel.ts +++ b/frontend/libs/model-list/src/lib/classes/VideoModel.ts @@ -4,6 +4,7 @@ import { ModelCategory } from "../legacy/ModelConfig.js"; import { ModelTag } from "./metadata/ModelTag.js"; import { SizeOption } from "./metadata/SizeOption.js"; import { GenerationProvider } from "@storyteller/api-enums"; +import { CommonAspectRatio } from "./properties/CommonAspectRatio.js"; export class VideoModel extends Model { // Typescript type discriminator property @@ -64,6 +65,9 @@ export class VideoModel extends Model { // NB: Soon all the models will support this. readonly supportsCommonAspectRatio: boolean; + // Canonical default declared by the model catalog + readonly defaultAspectRatio?: CommonAspectRatio; + // Default resolution readonly defaultResolution?: string; @@ -100,6 +104,7 @@ export class VideoModel extends Model { defaultResolution?: string; supportsSystemPrompt?: boolean; supportsCommonAspectRatio?: boolean; + defaultAspectRatio?: CommonAspectRatio; maxPromptLength?: number; }) { super(args); @@ -121,5 +126,6 @@ export class VideoModel extends Model { this.defaultResolution = args.defaultResolution; this.supportsSystemPrompt = args.supportsSystemPrompt ?? true; this.supportsCommonAspectRatio = args.supportsCommonAspectRatio ?? false; + this.defaultAspectRatio = args.defaultAspectRatio; } } diff --git a/frontend/libs/model-list/src/lib/classes/properties/VideoAspectRatio.spec.ts b/frontend/libs/model-list/src/lib/classes/properties/VideoAspectRatio.spec.ts new file mode 100644 index 0000000000..127cdff7d1 --- /dev/null +++ b/frontend/libs/model-list/src/lib/classes/properties/VideoAspectRatio.spec.ts @@ -0,0 +1,112 @@ +import { CommonAspectRatio } from "./CommonAspectRatio.js"; +import { SizeIconOption } from "../metadata/SizeOption.js"; +import { + resolveVideoAspectRatio, + resolveVideoAspectRatioOption, +} from "./VideoAspectRatio.js"; +import { buildVideoModelsFromListing } from "../../loader/buildModelsFromListing.js"; + +const options = [ + { + textLabel: "21:9", + tauriValue: CommonAspectRatio.WideTwentyOneByNine, + icon: SizeIconOption.Landscape16x9, + }, + { + textLabel: "16:9", + tauriValue: CommonAspectRatio.WideSixteenByNine, + icon: SizeIconOption.Landscape16x9, + }, +]; + +describe("resolveVideoAspectRatio", () => { + it("preserves a current selection that remains valid", () => { + expect( + resolveVideoAspectRatio( + { + sizeOptions: options, + defaultAspectRatio: CommonAspectRatio.WideSixteenByNine, + }, + "21:9", + ), + ).toBe("21:9"); + }); + + it("uses the declared default instead of assuming the first option", () => { + expect( + resolveVideoAspectRatio( + { + sizeOptions: options, + defaultAspectRatio: CommonAspectRatio.WideSixteenByNine, + }, + "4:3", + ), + ).toBe("16:9"); + expect( + resolveVideoAspectRatioOption( + { + sizeOptions: options, + defaultAspectRatio: CommonAspectRatio.WideSixteenByNine, + }, + "4:3", + )?.tauriValue, + ).toBe(CommonAspectRatio.WideSixteenByNine); + }); + + it("falls back to the first option when the default is missing or invalid", () => { + expect(resolveVideoAspectRatio({ sizeOptions: options }, null)).toBe( + "21:9", + ); + expect( + resolveVideoAspectRatio( + { sizeOptions: options, defaultAspectRatio: "future_ratio" }, + null, + ), + ).toBe("21:9"); + }); + + it("returns null when the model has no aspect-ratio options", () => { + expect(resolveVideoAspectRatio({ sizeOptions: [] }, "16:9")).toBeNull(); + }); + + it("hydrates a valid server default without reordering the options", () => { + const [model] = buildVideoModelsFromListing( + [], + [ + { + model: "fictional_video", + aspect_ratio_options: [ + CommonAspectRatio.WideTwentyOneByNine, + CommonAspectRatio.WideSixteenByNine, + ], + aspect_ratio_default: CommonAspectRatio.WideSixteenByNine, + }, + ], + ["fictional_video"], + ); + + expect(model.sizeOptions.map((option) => option.textLabel)).toEqual([ + "21:9", + "16:9", + ]); + expect(model.defaultAspectRatio).toBe(CommonAspectRatio.WideSixteenByNine); + expect(resolveVideoAspectRatio(model, "4:3")).toBe("16:9"); + }); + + it("drops a declared default that is not a supported enum value", () => { + const [model] = buildVideoModelsFromListing( + [], + [ + { + model: "fictional_video", + aspect_ratio_options: [CommonAspectRatio.WideSixteenByNine], + aspect_ratio_default: "future_ratio", + }, + ], + ["fictional_video"], + ); + + expect(model.defaultAspectRatio).toBeUndefined(); + expect(resolveVideoAspectRatio(model, null)).toBe("16:9"); + }); +}); diff --git a/frontend/libs/model-list/src/lib/classes/properties/VideoAspectRatio.ts b/frontend/libs/model-list/src/lib/classes/properties/VideoAspectRatio.ts new file mode 100644 index 0000000000..f7dddbe1a8 --- /dev/null +++ b/frontend/libs/model-list/src/lib/classes/properties/VideoAspectRatio.ts @@ -0,0 +1,39 @@ +import type { SizeOption } from "../metadata/SizeOption.js"; + +export interface VideoAspectRatioCapabilities { + readonly sizeOptions?: readonly SizeOption[]; + readonly defaultAspectRatio?: string; +} + +/** + * Resolve the UI label for a video model's aspect ratio. + * + * A still-valid user selection wins. Otherwise the server-declared canonical + * default is matched through each option's Tauri value, with the first option + * retained only as a compatibility fallback for catalogs without a usable + * default. + */ +export function resolveVideoAspectRatioOption( + model: VideoAspectRatioCapabilities | null | undefined, + currentLabel: string | null | undefined, +): SizeOption | null { + const options = model?.sizeOptions ?? []; + if (options.length === 0) return null; + + const currentOption = options.find( + (option) => option.textLabel === currentLabel, + ); + if (currentOption) return currentOption; + + const defaultOption = options.find( + (option) => option.tauriValue === model?.defaultAspectRatio, + ); + return defaultOption ?? options[0]!; +} + +export function resolveVideoAspectRatio( + model: VideoAspectRatioCapabilities | null | undefined, + currentLabel: string | null | undefined, +): string | null { + return resolveVideoAspectRatioOption(model, currentLabel)?.textLabel ?? null; +} diff --git a/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts b/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts index 0eb317ecfd..102ecc1b1e 100644 --- a/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts +++ b/frontend/libs/model-list/src/lib/loader/buildModelsFromListing.ts @@ -283,6 +283,10 @@ const mergedVideoModel = ( : o?.sizeOptions, supportsCommonAspectRatio: aspectRatios.length > 0 || (o?.supportsCommonAspectRatio ?? false), + defaultAspectRatio: + (knownValue(m.aspect_ratio_default, COMMON_ASPECT_RATIO_VALUES) as + | CommonAspectRatio + | undefined) ?? o?.defaultAspectRatio, }); }; From 30a0d09ce272464d43c49e21555dda9b937175a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?math=2E=CE=BC=20=E2=9C=AF?= <96264224+matthewpenkala@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:48:34 -0600 Subject: [PATCH 2/2] Run aspect-ratio tests in jsdom --- .../src/lib/classes/properties/VideoAspectRatio.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/libs/model-list/src/lib/classes/properties/VideoAspectRatio.spec.ts b/frontend/libs/model-list/src/lib/classes/properties/VideoAspectRatio.spec.ts index 127cdff7d1..a8a08f0f31 100644 --- a/frontend/libs/model-list/src/lib/classes/properties/VideoAspectRatio.spec.ts +++ b/frontend/libs/model-list/src/lib/classes/properties/VideoAspectRatio.spec.ts @@ -1,3 +1,5 @@ +// @vitest-environment jsdom + import { CommonAspectRatio } from "./CommonAspectRatio.js"; import { SizeIconOption } from "../metadata/SizeOption.js"; import {