Added new models and modes: GPT Image 2.5, Wan, Output Formats (#1954)

This commit is contained in:
Brandon Thomas
2026-09-18 16:23:44 -04:00
committed by GitHub
parent 584fdb7044
commit 3c5038c159
58 changed files with 883 additions and 78 deletions
@@ -12,12 +12,19 @@ export enum CommonImageModel {
GptImage2 = "gpt_image_2",
GptImage2p5Flare = "gpt_image_2p5_flare",
GptImage2p5Sunburst = "gpt_image_2p5_sunburst",
GrokImagineImage = "grok_imagine_image",
GrokImagineImageQuality = "grok_imagine_image_q",
Midjourney7 = "midjourney_7",
Midjourney7Niji = "midjourney_7_niji",
Midjourney8 = "midjourney_8",
NanaBanana = "nano_banana",
NanaBanana2 = "nano_banana_2",
NanaBananaPro = "nano_banana_pro",
Seedream4 = "seedream_4",
Seedream4p5 = "seedream_4p5",
Seedream5Lite = "seedream_5_lite",
Seedream5p0Pro = "seedream_5p0_pro",
Seedream5p0ProUltra = "seedream_5p0_pro_u",
QwenEdit2511Angles = "qwen_edit_2511_angles",
Flux2LoraAngles = "flux_2_lora_angles",
}
@@ -3,6 +3,9 @@
// In the future, we should use code gen (protobufs or similar) to keep the two sides in sync.
export enum CommonQuality {
Auto = "auto",
Max = "max",
XHigh = "xhigh",
High = "high",
Medium = "medium",
Low = "low",
@@ -4,6 +4,10 @@
export enum CommonVideoModel {
GrokVideo = "grok_video",
GrokImagineVideo = "grok_imagine_video",
GrokImagineVideo1p5 = "grok_imagine_video_1p5",
Flux3 = "flux_3",
Flux3Draft = "flux_3_draft",
Kling16Pro = "kling_1p6_pro",
Kling21Pro = "kling_2p1_pro",
Kling21Master = "kling_2p1_master",
@@ -11,10 +15,28 @@ export enum CommonVideoModel {
Kling2p6Pro = "kling_2p6_pro",
Kling3p0Standard = "kling_3p0_standard",
Kling3p0Pro = "kling_3p0_pro",
HappyHorse1p0 = "happy_horse_1p0",
Wan3p0 = "wan_3p0",
Wan3p0Prime = "wan_3p0_prime",
MinimaxH3 = "minimax_h3",
MinimaxH3Turbo = "minimax_h3_turbo",
MinimaxH3Ultra = "minimax_h3_ultra",
Seedance10Lite = "seedance_1p0_lite",
Seedance1p5Pro = "seedance_1p5_pro",
Seedance2p0 = "seedance_2p0",
Seedance2p0Fast = "seedance_2p0_fast",
Seedance2p0BytePlus = "seedance_2p0_bp",
Seedance2p0BytePlusFast = "seedance_2p0_bp_fast",
Seedance2p0Ultra = "seedance_2p0_u",
Seedance2p0UltraFast = "seedance_2p0_u_fast",
Seedance2p0BytePlusUltra = "seedance_2p0_bpu",
Seedance2p0BytePlusUltraFast = "seedance_2p0_bpu_fast",
Seedance2p0Mini = "seedance_2p0_mini",
Seedance2p0BytePlusMini = "seedance_2p0_bp_mini",
Seedance2p0BytePlusUltraMini = "seedance_2p0_bpu_mini",
Seedance2p5Preview = "seedance_2p5_preview",
Seedance2p5 = "seedance_2p5",
Seedance2p5Ultra = "seedance_2p5_u",
Sora2 = "sora_2",
Sora2Pro = "sora_2_pro",
Veo2 = "veo_2",
@@ -22,4 +44,9 @@ export enum CommonVideoModel {
Veo3Fast = "veo_3_fast",
Veo3p1 = "veo_3p1",
Veo3p1Fast = "veo_3p1_fast",
Veo3p1Lite = "veo_3p1_lite",
ViduQ3 = "vidu_q3",
ViduQ3Turbo = "vidu_q3_turbo",
PreviewModel = "preview_model",
PreviewModelFast = "preview_model_fast",
}
@@ -3,12 +3,18 @@ import { CommonQuality } from "@storyteller/api-enums";
/**
* Convert a quality value to CommonQuality for the image cost estimate API.
*
* Accepts a CommonQuality string value from @storyteller/model-list ("high", "medium", "low").
* Preserves all API quality tiers, including values added after this build.
*/
export function stringToCommonQuality(
quality: string | undefined,
): string | null {
switch (quality) {
case "auto":
return CommonQuality.Auto;
case "max":
return CommonQuality.Max;
case "xhigh":
return CommonQuality.XHigh;
case "high":
return CommonQuality.High;
case "medium":
@@ -78,7 +78,7 @@ export function useImageCostEstimate(
// Match GenerateImage exactly; omitted values use the API defaults.
aspectRatioStr = imageModel.supportsNewAspectRatio() ? imageAspectRatio : undefined;
resolutionStr = imageModel.supportsNewResolution() ? imageResolution : undefined;
qualityStr = imageModel.supportsQuality() ? imageQuality ?? imageModel.defaultQuality : undefined;
qualityStr = imageModel.resolveQuality(imageQuality);
referenceImageCount = imageModel.canUseImagePrompt
? imageReferenceImages.filter((image) => image.mediaToken.length > 0).length : 0;
generationCount = imageGenerationCount;
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
referenceVideos: [{ mediaToken: "mf_video", duration: 7.25 }],
referenceAudios: [{ mediaToken: "mf_audio", duration: 3.5 }],
bitrate: "high",
outputFormat: "mov",
generationCount: 1,
generateWithSound: true,
},
@@ -38,6 +39,7 @@ vi.mock("./cost-breakdown-modal-store", () => ({
}));
beforeEach(() => {
mocks.state.generationCount = 1;
mocks.estimate.mockReset();
mocks.setCredits.mockReset();
mocks.estimate.mockResolvedValue({
@@ -61,6 +63,7 @@ it("quotes a future model with the chosen bitrate and reference durations in mil
model: "future_video",
resolution: "four_k",
bitrate: "high",
output_format: "mov",
duration_seconds: 30,
reference_video_media_tokens: ["mf_video"],
reference_audio_media_tokens: ["mf_audio"],
@@ -72,6 +75,20 @@ it("quotes a future model with the chosen bitrate and reference durations in mil
);
});
it("prices all selected generations for any model with API batch options", async () => {
mocks.state.generationCount = 3;
renderHook(() => useVideoCostEstimate(ModelPage.ImageToVideo, model, "artcraft"));
await waitFor(() => expect(mocks.setCredits).toHaveBeenLastCalledWith(ModelPage.ImageToVideo, 126));
});
it("clears format options and clamps stale batch counts for unsupported models", async () => {
mocks.state.generationCount = 4;
const single = { ...model, generationCountOptions: [1], outputFormatOptions: undefined } as VideoModel;
renderHook(() => useVideoCostEstimate(ModelPage.ImageToVideo, single, "artcraft"));
await waitFor(() => expect(mocks.setCredits).toHaveBeenLastCalledWith(ModelPage.ImageToVideo, 42));
expect(mocks.estimate).toHaveBeenCalledWith(expect.objectContaining({ output_format: undefined }));
});
it("does not overwrite a newer model's quote with a slow earlier response", async () => {
let finishOld: (result: unknown) => void = () => {};
mocks.estimate.mockImplementationOnce(
@@ -111,6 +128,10 @@ const model = {
resolutionOptions: ["4K"],
bitrateOptions: ["normal", "high"],
defaultBitrate: "normal",
outputFormatOptions: ["mp4", "mov"],
defaultOutputFormat: "mp4",
generationCountOptions: [1, 2, 3, 4],
defaultGenerationCount: 1,
generateWithSound: true,
sizeOptions: [{ tauriValue: "wide_sixteen_by_nine", textLabel: "16:9" }],
} as VideoModel;
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { ModelPage } from "@storyteller/ui-model-selector";
import { Model, VideoModel, resolveVideoDuration, videoResolutionValue } from "@storyteller/model-list";
import { Model, VideoModel, resolveVideoDuration, resolveVideoGenerationCount, resolveVideoOutputFormat, videoResolutionValue } from "@storyteller/model-list";
import { GenerationProvider } from "@storyteller/api-enums";
import { usePromptVideoStore } from "@storyteller/ui-promptbox";
import {
@@ -32,6 +32,7 @@ export function useVideoCostEstimate(
const referenceVideos = usePromptVideoStore((s) => s.referenceVideos);
const referenceAudios = usePromptVideoStore((s) => s.referenceAudios);
const bitrate = usePromptVideoStore((s) => s.bitrate);
const outputFormat = usePromptVideoStore((s) => s.outputFormat);
const generationCount = usePromptVideoStore((s) => s.generationCount);
const generateWithSound = usePromptVideoStore((s) => s.generateWithSound);
@@ -72,6 +73,7 @@ export function useVideoCostEstimate(
duration_seconds: resolveVideoDuration(videoModel, duration, isReferenceMode) ?? undefined,
generate_audio: videoModel.generateWithSound ? generateWithSound : undefined,
bitrate: videoModel.bitrateOptions?.length ? bitrate ?? videoModel.defaultBitrate ?? videoModel.bitrateOptions[0] : undefined,
output_format: resolveVideoOutputFormat(videoModel, outputFormat),
start_frame_image_media_token: !isReferenceMode ? referenceImages[0]?.mediaToken : undefined,
end_frame_image_media_token: !isReferenceMode ? endFrameImage?.mediaToken : undefined,
reference_image_media_tokens: isReferenceMode ? referenceImages.map((image) => image.mediaToken) : undefined,
@@ -88,7 +90,7 @@ export function useVideoCostEstimate(
if (cancelled) return;
if (isEstimateVideoCostSuccess(result)) {
const unitCredits = result.payload.cost_in_credits;
const count = videoModel.id === "seedance_2p0" ? generationCount : 1;
const count = resolveVideoGenerationCount(videoModel, generationCount);
const credits = unitCredits == null ? null : unitCredits * count;
setEstimatedCreditsForPage(ModelPage.ImageToVideo, credits);
} else {
@@ -115,6 +117,7 @@ export function useVideoCostEstimate(
referenceVideos,
referenceAudios,
bitrate,
outputFormat,
generationCount,
endFrameImage,
generateWithSound,
@@ -407,7 +407,7 @@ export const PromptBoxImage = ({
}
if (selectedModel?.supportsQuality()) {
request.quality = commonQuality ?? selectedModel.defaultQuality;
request.quality = selectedModel.resolveQuality(commonQuality);
}
if (
@@ -11,6 +11,9 @@ import { DynamicIcon } from "@storyteller/icons";
import { arrayMove } from "@dnd-kit/sortable";
import {
resolveVideoDuration,
resolveVideoGenerationCount,
resolveVideoOutputFormat,
videoGenerationCounts,
videoDurationRange,
videoResolutionValue,
effectivePromptMaxLength,
@@ -143,6 +146,8 @@ export const PromptBoxVideo = ({
const duration = usePromptVideoStore((s) => s.duration);
const bitrate = usePromptVideoStore((s) => s.bitrate);
const setBitrate = usePromptVideoStore((s) => s.setBitrate);
const outputFormat = usePromptVideoStore((s) => s.outputFormat);
const setOutputFormat = usePromptVideoStore((s) => s.setOutputFormat);
const setDuration = usePromptVideoStore((s) => s.setDuration);
const inputMode = usePromptVideoStore((s) => s.inputMode);
const setInputMode = usePromptVideoStore((s) => s.setInputMode);
@@ -361,6 +366,14 @@ export const PromptBoxVideo = ({
}
}, [selectedModel, setBitrate]);
useEffect(() => {
const current = usePromptVideoStore.getState().outputFormat;
const options = selectedModel?.outputFormatOptions;
if (!current || !options?.includes(current)) {
setOutputFormat(selectedModel?.defaultOutputFormat ?? options?.[0] ?? null);
}
}, [selectedModel, setOutputFormat]);
useEffect(() => {
const current = usePromptVideoStore.getState().aspectRatio;
if (selectedModel?.sizeOptions.some((option) => option.textLabel === current)) return;
@@ -396,15 +409,16 @@ export const PromptBoxVideo = ({
}
}, [selectedModel]);
// Reset generation count when switching away from seedance 2.0.
// Read from store directly to avoid stale closure (same as duration above).
// Keep the selected count within the API's options when switching models.
useEffect(() => {
const currentGenerationCount =
usePromptVideoStore.getState().generationCount;
if (selectedModel?.id !== "seedance_2p0" && currentGenerationCount > 1) {
setGenerationCount(1);
}
}, [selectedModel]);
const current = usePromptVideoStore.getState().generationCount;
const next = selectedModel ? resolveVideoGenerationCount(selectedModel, current) : 1;
if (next !== current) setGenerationCount(next);
}, [selectedModel, setGenerationCount]);
const generationCounts = selectedModel ? videoGenerationCounts(selectedModel) : [1];
const effectiveGenerationCount = selectedModel
? resolveVideoGenerationCount(selectedModel, generationCount) : 1;
const durationRange = selectedModel
? videoDurationRange(selectedModel, inputMode === "reference")
@@ -1049,8 +1063,7 @@ export const PromptBoxVideo = ({
gtagEvent("enqueue_video");
const isSeedance2 = selectedModel.id === "seedance_2p0";
const count = isSeedance2 ? generationCount : 1;
const count = effectiveGenerationCount;
const isRefMode =
inputMode === "reference" && !!selectedModel.supportsReferenceMode;
@@ -1145,6 +1158,9 @@ export const PromptBoxVideo = ({
if (selectedModel.bitrateOptions?.length) {
request.bitrate = bitrate ?? selectedModel.defaultBitrate ?? selectedModel.bitrateOptions[0];
}
if (selectedModel.outputFormatOptions?.length) {
request.output_format = resolveVideoOutputFormat(selectedModel, outputFormat);
}
// Pass the chosen resolution when the model exposes a resolution picker.
// Guarded on `resolutionOptions` so a stale store value (left over from a
@@ -1454,6 +1470,21 @@ export const PromptBoxVideo = ({
</Tooltip>
)}
{!!selectedModel?.outputFormatOptions?.length && (
<Tooltip content="Output format" position="top" className="z-50">
<PopoverMenu
panelTitle="Output format"
mode="toggle"
items={selectedModel.outputFormatOptions.map((value) => ({
label: value.toUpperCase(),
action: value,
selected: value === outputFormat,
}))}
onSelect={(item) => setOutputFormat(item.action ?? item.label)}
/>
</Tooltip>
)}
{durationRange && durationRange.max > durationRange.min && (
<Tooltip content="Duration" position="top" className="z-50">
<PopoverMenu
@@ -1522,10 +1553,10 @@ export const PromptBoxVideo = ({
disabled={!hasClearableContent}
confirmClear={hasAttachedRefs}
/>
{selectedModel?.id === "seedance_2p0" && (
{generationCounts.length > 1 && (
<VideoGenerationCountPicker
maxCount={4}
currentCount={generationCount}
counts={generationCounts}
currentCount={effectiveGenerationCount}
handleCountChange={setGenerationCount}
/>
)}
@@ -1541,9 +1572,7 @@ export const PromptBoxVideo = ({
onClick={handleEnqueue}
disabled={!prompt.trim()}
loading={isEnqueueing}
credits={
credits != null ? credits * generationCount : credits
}
credits={credits}
/>
</div>
</Tooltip>
@@ -10,6 +10,9 @@ interface QualityPickerProps {
}
const QUALITY_LABELS: Record<CommonQuality, string> = {
[CommonQuality.Auto]: "Auto",
[CommonQuality.Max]: "Max",
[CommonQuality.XHigh]: "Extra high",
[CommonQuality.High]: "High",
[CommonQuality.Medium]: "Medium",
[CommonQuality.Low]: "Low",
@@ -17,7 +20,7 @@ const QUALITY_LABELS: Record<CommonQuality, string> = {
/**
* Stateless picker for image generation "quality" (used by OpenAI image
* models — gpt_image_1, gpt_image_1p5, gpt_image_2). Models that don't
* models, including GPT Image 2.5). Models that don't
* declare `qualities` should not render this picker.
*/
export const QualityPicker = ({
@@ -25,7 +28,7 @@ export const QualityPicker = ({
currentQuality,
handleCommonQualitySelect,
}: QualityPickerProps) => {
const useQuality = currentQuality ?? model.defaultQuality ?? undefined;
const useQuality = model.resolveQuality(currentQuality);
const handleSelectAdapter = (item: PopoverItem) => {
const quality = (item.action ?? item.label) as CommonQuality;
@@ -4,27 +4,24 @@ import { Tooltip } from "@storyteller/ui-tooltip";
import { PROMPT_TOOLBAR_ICON_BUTTON_CLASSES } from "../PromptClearAllButton";
interface VideoGenerationCountPickerProps {
maxCount: number;
counts: number[];
currentCount: number;
handleCountChange: (count: number) => void;
}
export const VideoGenerationCountPicker = ({
maxCount,
counts,
currentCount,
handleCountChange,
}: VideoGenerationCountPickerProps) => {
const options: PopoverItem[] = [];
for (let i = 1; i <= maxCount; i++) {
options.push({
label: String(i),
selected: i === currentCount,
});
}
const options: PopoverItem[] = counts.map((count) => ({
label: String(count),
selected: count === currentCount,
}));
const onSelect = (item: PopoverItem) => {
const count = parseInt(item.label, 10);
if (!isNaN(count) && count >= 1 && count <= maxCount) {
if (counts.includes(count)) {
handleCountChange(count);
}
};
@@ -165,6 +165,7 @@ interface PromptVideoStore {
generateWithSound: boolean;
duration: number | null;
bitrate: string | null;
outputFormat: string | null;
inputMode: VideoInputMode;
generationCount: number;
setPrompt: (prompt: string) => void;
@@ -178,6 +179,7 @@ interface PromptVideoStore {
setGenerateWithSound: (value: boolean) => void;
setDuration: (duration: number | null) => void;
setBitrate: (bitrate: string | null) => void;
setOutputFormat: (outputFormat: string | null) => void;
setInputMode: (mode: VideoInputMode) => void;
setGenerationCount: (count: number) => void;
}
@@ -194,6 +196,7 @@ export const usePromptVideoStore = create<PromptVideoStore>()((set) => ({
generateWithSound: true,
duration: null,
bitrate: null,
outputFormat: null,
inputMode: "reference",
generationCount: 1,
setPrompt: (prompt) => set({ prompt }),
@@ -207,6 +210,7 @@ export const usePromptVideoStore = create<PromptVideoStore>()((set) => ({
setGenerateWithSound: (generateWithSound) => set({ generateWithSound }),
setDuration: (duration) => set({ duration }),
setBitrate: (bitrate) => set({ bitrate }),
setOutputFormat: (outputFormat) => set({ outputFormat }),
setInputMode: (inputMode) => set({ inputMode }),
setGenerationCount: (generationCount) => set({ generationCount }),
}));
@@ -189,6 +189,13 @@ export class ImageModel extends Model {
return this.qualityOptions && this.qualityOptions.length > 0;
}
// A saved tier from another model may not be supported by this model.
resolveQuality(current?: CommonQuality): CommonQuality | undefined {
if (!this.supportsQuality()) return undefined;
return current && this.qualityOptions.includes(current)
? current : this.defaultQuality ?? this.qualityOptions[0];
}
// Return whether the count of generations is valid for this model
isValidGenerationCount(count: number): boolean {
if (this.predefinedGenerationCounts) {
@@ -41,6 +41,10 @@ export class VideoModel extends Model {
readonly maxDurationWithImageReferences?: number;
readonly bitrateOptions?: string[];
readonly defaultBitrate?: string;
readonly outputFormatOptions?: string[];
readonly defaultOutputFormat?: string;
readonly generationCountOptions?: number[];
readonly defaultGenerationCount?: number;
readonly defaultAspectRatio?: string;
@@ -102,6 +106,10 @@ export class VideoModel extends Model {
maxDurationWithImageReferences?: number;
bitrateOptions?: string[];
defaultBitrate?: string;
outputFormatOptions?: string[];
defaultOutputFormat?: string;
generationCountOptions?: number[];
defaultGenerationCount?: number;
defaultAspectRatio?: string;
supportsReferenceMode?: boolean;
@@ -130,6 +138,10 @@ export class VideoModel extends Model {
this.maxDurationWithImageReferences = args.maxDurationWithImageReferences;
this.bitrateOptions = args.bitrateOptions;
this.defaultBitrate = args.defaultBitrate;
this.outputFormatOptions = args.outputFormatOptions;
this.defaultOutputFormat = args.defaultOutputFormat;
this.generationCountOptions = args.generationCountOptions;
this.defaultGenerationCount = args.defaultGenerationCount;
this.defaultAspectRatio = args.defaultAspectRatio;
this.supportsReferenceMode = args.supportsReferenceMode;
@@ -24,6 +24,7 @@ export const MODEL_ID_PREFIX_CREATORS: Array<[string, ModelCreator]> = [
["vidu", ModelCreator.Vidu],
["recraft", ModelCreator.Recraft],
["happy_horse", ModelCreator.Alibaba],
["wan", ModelCreator.Alibaba],
["minimax", ModelCreator.Hailuo],
["qwen", ModelCreator.Alibaba],
["suno", ModelCreator.Suno],
@@ -13,6 +13,7 @@ const MODEL_ID_PREFIX_FAMILIES: Array<[string, string]> = [
["minimax", "MiniMax"],
["hailuo", "MiniMax"],
["happy_horse", "Happy Horse"],
["wan", "Wan"],
["vidu", "Vidu"],
// ── Image ── (flux also covers the Flux 3 video model)
["flux", "Flux"],
@@ -50,6 +51,7 @@ export const FAMILY_ORDER: string[] = [
"Grok",
"Vidu",
"Happy Horse",
"Wan",
// Image families.
"Nano Banana",
"Seedream",
@@ -96,7 +96,10 @@ export const getModelDisplayName = (
seedance_2p0_bpu_fast: "Seedance 2.0 Plus Ultra Fast",
seedance_2p0_bpu_mini: "Seedance 2.0 Plus Ultra Mini",
seedance_2p5: "Seedance 2.5",
seedance_2p5_u: "Seedance 2.5 Ultra",
seedance_2p5_preview: "Seedance 2.5 Preview",
wan_3p0: "Wan 3.0",
wan_3p0_prime: "Wan 3.0 Prime",
// Seedance — dot-normalized aliases
seedance_1_0_lite: "Seedance 1.0 Lite",
// Anonymous preview aliases the backend used pre-announce
@@ -2,6 +2,9 @@
/// Not all models have each of these qualities,
/// but Tauri will interpolate if a mistake is sent.
export enum CommonQuality {
Auto = "auto",
Max = "max",
XHigh = "xhigh",
High = "high",
Medium = "medium",
Low = "low",
@@ -1,5 +1,23 @@
import type { VideoModel } from "./VideoModel.js";
export function resolveVideoOutputFormat(model: VideoModel, current?: string | null): string | undefined {
const options = model.outputFormatOptions;
if (!options?.length) return undefined;
if (current && options.includes(current)) return current;
return model.defaultOutputFormat ?? options[0];
}
export function videoGenerationCounts(model: VideoModel): number[] {
return model.generationCountOptions?.length ? model.generationCountOptions : [1];
}
export function resolveVideoGenerationCount(model: VideoModel, current: number): number {
const options = videoGenerationCounts(model);
if (options.includes(current)) return current;
return model.defaultGenerationCount != null && options.includes(model.defaultGenerationCount)
? model.defaultGenerationCount : options[0];
}
/** Normalize old display values while preserving future API values. */
export function videoResolutionValue(value: string): string {
const legacy: Record<string, string> = {
@@ -403,7 +403,7 @@ export const IMAGE_MODELS: ImageModel[] = [
progressBarTime: 60000,
maxPromptLength: Infinity,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.WideSixteenByNine,
defaultAspectRatio: CommonAspectRatio.Square,
aspectRatios: [
CommonAspectRatio.Auto,
CommonAspectRatio.Square,
@@ -413,8 +413,19 @@ export const IMAGE_MODELS: ImageModel[] = [
CommonAspectRatio.WideFourByThree,
CommonAspectRatio.WideSixteenByNine,
],
canChangeResolution: true,
resolutions: [
CommonResolution.OneK,
CommonResolution.TwoK,
CommonResolution.ThreeK,
CommonResolution.FourK,
],
defaultResolution: CommonResolution.OneK,
defaultQuality: CommonQuality.High,
qualityOptions: [
CommonQuality.Auto,
CommonQuality.Max,
CommonQuality.XHigh,
CommonQuality.High,
CommonQuality.Medium,
CommonQuality.Low,
@@ -438,7 +449,7 @@ export const IMAGE_MODELS: ImageModel[] = [
progressBarTime: 120000,
maxPromptLength: Infinity,
canChangeAspectRatio: true,
defaultAspectRatio: CommonAspectRatio.WideSixteenByNine,
defaultAspectRatio: CommonAspectRatio.Square,
aspectRatios: [
CommonAspectRatio.Auto,
CommonAspectRatio.Square,
@@ -448,8 +459,19 @@ export const IMAGE_MODELS: ImageModel[] = [
CommonAspectRatio.WideFourByThree,
CommonAspectRatio.WideSixteenByNine,
],
canChangeResolution: true,
resolutions: [
CommonResolution.OneK,
CommonResolution.TwoK,
CommonResolution.ThreeK,
CommonResolution.FourK,
],
defaultResolution: CommonResolution.OneK,
defaultQuality: CommonQuality.High,
qualityOptions: [
CommonQuality.Auto,
CommonQuality.Max,
CommonQuality.XHigh,
CommonQuality.High,
CommonQuality.Medium,
CommonQuality.Low,
@@ -345,6 +345,8 @@ export const VIDEO_MODELS: VideoModel[] = [
new VideoModel({
id: "seedance_2p0",
tauriId: "seedance_2p0",
generationCountOptions: [1, 2, 3, 4],
defaultGenerationCount: 1,
fullName: "Seedance 2.0",
category: "video",
creator: ModelCreator.Bytedance,
@@ -7,10 +7,14 @@ import {
} from "./buildModelsFromListing.js";
import {
resolveVideoDuration,
resolveVideoGenerationCount,
resolveVideoOutputFormat,
videoGenerationCounts,
videoDurationRange,
videoResolutionValue,
} from "../classes/videoOptions.js";
import { IMAGE_MODELS } from "../lists/ImageModels.js";
import { CommonQuality } from "../classes/properties/CommonQuality.js";
import { VIDEO_MODELS } from "../lists/VideoModels.js";
import { SPLAT_MODELS } from "../lists/SplatModels.js";
@@ -24,6 +28,8 @@ describe("desktop catalog", () => {
"flux_3",
"minimax_h3",
"vidu_q3",
"wan_3p0",
"wan_3p0_prime",
"future_video_v99",
];
const models = buildVideoModelsFromListing(
@@ -97,6 +103,61 @@ describe("desktop catalog", () => {
});
describe("API video options", () => {
it.each(["wan_3p0", "wan_3p0_prime"])("hydrates %s with Wan's reference and duration capabilities", (id) => {
const [model] = buildVideoModelsFromListing([], [{
model: id,
model_creator: "alibaba",
text_to_video_supported: true,
text_prompt_max_length: 20000,
starting_keyframe_supported: true,
ending_keyframe_supported: true,
image_references_supported: true,
image_references_max: 10,
video_references_max: 5,
video_references_max_total_duration_seconds: 15,
audio_references_max: 5,
audio_references_max_total_duration_seconds: 15,
show_generate_with_sound_toggle: true,
resolution_options: ["four_eighty_p", "seven_twenty_p", "ten_eighty_p"],
resolution_default: "seven_twenty_p",
duration_seconds_min: 2,
duration_seconds_max: 30,
duration_seconds_default: 5,
batch_size_options: [1],
batch_size_default: 1,
}]);
expect(model.getProviders()).toEqual([GenerationProvider.Artcraft]);
expect(model).toMatchObject({
startFrame: true, endFrame: true, textToVideoSupported: true,
supportsReferenceMode: true, maxReferenceImages: 10,
maxReferenceVideos: 5, maxVideoRefDuration: 15,
maxReferenceAudios: 5, maxAudioRefDuration: 15,
generateWithSound: true, maxPromptLength: 20000,
resolutionOptions: ["480p", "720p", "1080p"], defaultResolution: "720p",
});
expect(videoDurationRange(model, true)).toEqual({ min: 2, max: 30 });
expect(resolveVideoDuration(model, null, false)).toBe(5);
expect(videoGenerationCounts(model)).toEqual([1]);
expect(resolveVideoGenerationCount(model, 4)).toBe(1);
});
it.each(["seedance_2p5", "seedance_2p5_u"])("preserves %s output formats and batch choices", (id) => {
const [model] = buildVideoModelsFromListing([], [{
model: id,
output_format_options: ["mp4", "mov", "future_format"],
output_format_default: "mp4",
batch_size_options: [1, 2, 3, 4],
batch_size_default: 1,
}]);
expect(model.outputFormatOptions).toEqual(["mp4", "mov", "future_format"]);
expect(model.defaultOutputFormat).toBe("mp4");
expect(resolveVideoOutputFormat(model, "mov")).toBe("mov");
expect(resolveVideoOutputFormat(model, "unsupported")).toBe("mp4");
expect(videoGenerationCounts(model)).toEqual([1, 2, 3, 4]);
expect(resolveVideoGenerationCount(model, 3)).toBe(3);
expect(resolveVideoGenerationCount(model, 5)).toBe(1);
});
it("uses range durations, reference-mode caps, bitrate, and future resolutions", () => {
const [model] = buildVideoModelsFromListing(
[],
@@ -134,6 +195,37 @@ describe("API video options", () => {
});
});
it.each(["gpt_image_2p5_flare", "gpt_image_2p5_sunburst"])("preserves every %s quality and resolution option", (id) => {
const qualities = ["auto", "max", "xhigh", "high", "medium", "low"];
const resolutions = ["one_k", "two_k", "three_k", "four_k"];
const model = buildImageModelsFromListing(IMAGE_MODELS, [{
model: id,
quality_options: qualities,
default_quality: "high",
resolution_options: resolutions,
resolution_default: "one_k",
image_refs_supported: true,
}]).find((m) => m.tauriId === id)!;
expect(model.qualityOptions).toEqual(qualities);
expect(model.defaultQuality).toBe("high");
expect(model.resolutions).toEqual(resolutions);
expect(model.defaultResolution).toBe("one_k");
expect(model.canEditImages).toBe(true);
expect(model.resolveQuality(CommonQuality.Max)).toBe("max");
expect(model.resolveQuality(CommonQuality.Auto)).toBe("auto");
});
it("uses the new model's default when a saved quality tier is unsupported", () => {
const [model] = buildImageModelsFromListing([], [{
model: "gpt_image_2",
quality_options: ["high", "medium", "low"],
default_quality: "high",
}]);
expect(model.resolveQuality(CommonQuality.Max)).toBe("high");
expect(model.resolveQuality(CommonQuality.XHigh)).toBe("high");
expect(model.resolveQuality(CommonQuality.Low)).toBe("low");
});
it("offers ArtCraft and direct Midjourney only for supported Midjourney models", () => {
const models = buildImageModelsFromListing([], [
{ model: "midjourney_7" }, { model: "midjourney_7_niji" },
@@ -76,6 +76,8 @@ export interface ListingVideoModel extends ListingModelBase {
duration_seconds_max_with_image_references?: number | null;
bitrate_options?: string[] | null;
bitrate_default?: string | null;
output_format_options?: string[] | null;
output_format_default?: string | null;
duration_seconds_default?: number | null;
}
@@ -269,6 +271,10 @@ const mergedVideoModel = (
maxDurationWithImageReferences: m.duration_seconds_max_with_image_references ?? o?.maxDurationWithImageReferences,
bitrateOptions: m.bitrate_options ?? o?.bitrateOptions,
defaultBitrate: m.bitrate_default ?? o?.defaultBitrate,
outputFormatOptions: m.output_format_options ?? o?.outputFormatOptions,
defaultOutputFormat: m.output_format_default ?? o?.defaultOutputFormat,
generationCountOptions: m.batch_size_options ?? o?.generationCountOptions,
defaultGenerationCount: m.batch_size_default ?? o?.defaultGenerationCount,
defaultAspectRatio: m.aspect_ratio_default ?? o?.defaultAspectRatio,
supportsReferenceMode: m.image_references_supported ?? o?.supportsReferenceMode,
maxReferenceImages: m.image_references_max ?? o?.maxReferenceImages,
@@ -16,6 +16,7 @@ export interface EstimateVideoCostRequest {
video_batch_count?: number;
generate_audio?: boolean;
bitrate?: string;
output_format?: string;
start_frame_image_media_token?: string;
end_frame_image_media_token?: string;
reference_image_media_tokens?: string[];
@@ -24,6 +24,7 @@ it("passes unknown model and option strings through image and video commands", a
const video = {
model: "future_video",
bitrate: "high",
output_format: "mov",
resolution: "eight_k",
duration_seconds: 30,
generate_audio: false,
@@ -54,6 +55,25 @@ it("dispatches every added modality and its pricing through Tauri", async () =>
]);
});
it.each(["gpt_image_2p5_flare", "gpt_image_2p5_sunburst"])("forwards every %s quality tier", async (model) => {
for (const quality of ["auto", "max", "xhigh", "high", "medium", "low"]) {
const request = { model, quality, resolution: "four_k" };
await GenerateImage(request);
expect(invoke).toHaveBeenLastCalledWith("generate_image_command", { request });
}
});
it.each(["wan_3p0", "wan_3p0_prime"])("forwards %s with reference media", async (model) => {
const request = {
model, duration_seconds: 15, resolution: "ten_eighty_p", generate_audio: true,
reference_image_media_tokens: ["mf_image"],
reference_video_media_tokens: ["mf_video"],
reference_audio_media_tokens: ["mf_audio"],
};
await GenerateVideo(request);
expect(invoke).toHaveBeenLastCalledWith("generate_video_command", { request });
});
it("preserves reference duration hints in video pricing", async () => {
const request = {
model: "future_video",
@@ -38,6 +38,7 @@ export interface GenerateVideoRequest {
resolution?: CommonResolution | string;
bitrate?: string;
output_format?: string;
duration_seconds?: number;
generate_audio?: boolean;
video_batch_count?: number;
@@ -55,6 +55,8 @@ export interface OmniGenVideoModelDetails {
resolution_default?: string;
bitrate_options?: string[];
bitrate_default?: string;
output_format_options?: string[];
output_format_default?: string;
quality_options?: string[];
default_quality?: string;
duration_seconds_min?: number;