mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Added new models and modes: GPT Image 2.5, Wan, Output Formats (#1954)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user