Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -88,7 +96,7 @@ export function useVideoCostEstimate(
});
}, [
activePage,
selectedModel?.id,
selectedModel,
selectedProvider,
duration,
aspectRatio,
Expand Down
67 changes: 49 additions & 18 deletions frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { arrayMove } from "@dnd-kit/sortable";
import {
CommonResolution,
effectivePromptMaxLength,
resolveVideoAspectRatio,
resolveVideoAspectRatioOption,
SizeIconOption,
SizeOption,
VideoModel,
Expand Down Expand Up @@ -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: <AspectRatioIcon sizeIcon={option.icon} />,
}));
};

if (!!selectedModel?.sizeOptions && selectedModel.sizeOptions.length > 0) {
aspectRatioOptions = buildAspectRatioOptions(selectedModel.sizeOptions);
aspectRatioOptions = buildAspectRatioOptions(
selectedModel.sizeOptions,
selectedModel.defaultAspectRatio,
);
} else {
aspectRatioOptions = buildAspectRatioOptions(DEFAULT_RESOLUTIONS);
}
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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;
}
}
}

Expand Down Expand Up @@ -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;
};

Expand Down
5 changes: 5 additions & 0 deletions frontend/libs/model-list/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
6 changes: 6 additions & 0 deletions frontend/libs/model-list/src/lib/classes/VideoModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -100,6 +104,7 @@ export class VideoModel extends Model {
defaultResolution?: string;
supportsSystemPrompt?: boolean;
supportsCommonAspectRatio?: boolean;
defaultAspectRatio?: CommonAspectRatio;
maxPromptLength?: number;
}) {
super(args);
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// @vitest-environment jsdom

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");
});
});
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
};

Expand Down