From 83115e7ef943cb9bfdb9077b0d34bcd9d977e294 Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 6 Jul 2026 19:42:56 +0700 Subject: [PATCH] Prompt character counters on web, seedance CJK limit waiver, and model creator mapping (#1735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Changes - **Prompt character counters on web**: the website and webapp promptboxes (image + video) now show a `123 / 2000` counter driven by the API's `text_prompt_max_length`, matching the desktop app. It turns red when over the limit and generation is blocked with a toast; typing is never clamped. The webapp's fullscreen prompt editor shows the counter too. - **Seedance CJK waiver**: seedance video models normally use the API limit, but the limit is waived (shows `∞`) while the prompt contains Chinese characters. Applies on desktop and web via a shared `effectivePromptMaxLength` helper. - **API type parity**: added the new `model_creator` field to the web `OmniGenImageModelInfo` / `OmniGenVideoModelInfo` types (desktop types were already up to date). - **Cleanup**: deleted the manual model-to-creator map (`MODEL_TYPE_TO_CREATOR`, ~150 lines) now that creator info comes from the API; lightbox model icons use the shared `getCreatorIconPathForModelId` lookup like every other surface. --- .../components/lightbox/LightboxDetails.tsx | 10 +- .../src/components/prompt-box/PromptBox.tsx | 25 ++- .../prompt-box/PromptFullscreen.tsx | 21 +++ .../src/pages/create-image/create-image.tsx | 12 ++ .../src/pages/create-video/create-video.tsx | 21 +++ frontend/apps/artcraft-webapp/src/styles.css | 37 ++++ .../components/lightbox/LightboxDetails.tsx | 10 +- .../src/components/prompt-box/PromptBox.tsx | 23 ++- .../src/pages/create-image/create-image.tsx | 13 ++ .../src/pages/create-video/create-video.tsx | 26 ++- frontend/apps/artcraft-website/src/styles.css | 37 ++++ frontend/libs/api/src/lib/OmniGenApi.ts | 4 + .../lightbox-modal/src/lib/lightbox-modal.tsx | 8 +- .../promptbox/src/lib/PromptBoxVideo.tsx | 8 +- .../src/lib/classes/metadata/ModelMapping.ts | 172 ++---------------- 15 files changed, 264 insertions(+), 163 deletions(-) diff --git a/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx b/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx index 3b3148bd7a..dbd0a7f55a 100644 --- a/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx +++ b/frontend/apps/artcraft-webapp/src/components/lightbox/LightboxDetails.tsx @@ -28,7 +28,7 @@ import { THUMBNAIL_SIZES, } from "@storyteller/common"; import { - getModelCreatorIcon, + getCreatorIconPathForModelId, getModelDisplayName, getProviderDisplayName, getProviderIconByName, @@ -361,7 +361,13 @@ export function LightboxDetails({ label="Model" value={ <> - {getModelCreatorIcon(promptData.modelType)} + Model creator logo {getModelDisplayName(promptData.modelType)} diff --git a/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptBox.tsx b/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptBox.tsx index a8892acc38..9930cbf01f 100644 --- a/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptBox.tsx +++ b/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptBox.tsx @@ -72,6 +72,11 @@ interface PromptBoxProps { // @-mention support (enables colored prompt overlay + autocomplete) mentionItems?: MentionItem[]; + + // Soft prompt-length limit from the model API (`text_prompt_max_length`). + // Undefined = unlimited (no counter). The limit is not enforced here; the + // page's submit handler blocks generation when over. + maxPromptLength?: number; } export const PromptBox = forwardRef( @@ -102,6 +107,7 @@ export const PromptBox = forwardRef( mediaReferenceRow, modelSelector, mentionItems, + maxPromptLength, }, ref, ) => { @@ -391,7 +397,7 @@ export const PromptBox = forwardRef( )} -
+
{hasMentionItems && mentionItems ? ( ( )} + + {maxPromptLength !== undefined && ( +
maxPromptLength + ? "text-red-500" + : "text-base-fg/40", + )} + > + {prompt.length} /{" "} + {isFinite(maxPromptLength) ? maxPromptLength : "∞"} +
+ )}
@@ -565,6 +586,8 @@ export const PromptBox = forwardRef( {modelSelector} diff --git a/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptFullscreen.tsx b/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptFullscreen.tsx index 8e430787c1..867250064b 100644 --- a/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptFullscreen.tsx +++ b/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptFullscreen.tsx @@ -57,6 +57,10 @@ interface PromptFullscreenModalProps { isOpen: boolean; onClose: () => void; children: ReactNode; + /** Current prompt length, shown with the counter when a limit is set. */ + promptLength?: number; + /** Soft prompt-length limit; undefined hides the counter entirely. */ + maxPromptLength?: number; /** Controls rendered in the footer, left of the Done button (e.g. the model * selector + character button). */ footerControls?: ReactNode; @@ -68,9 +72,15 @@ export const PromptFullscreenModal = ({ isOpen, onClose, children, + promptLength, + maxPromptLength, footerControls, imagePromptRow, }: PromptFullscreenModalProps) => { + const showCounter = + promptLength !== undefined && maxPromptLength !== undefined; + const overLimit = + showCounter && isFinite(maxPromptLength) && promptLength > maxPromptLength; const contentRef = useRef(null); // Focus the editor and drop the caret at the end once the overlay opens. @@ -106,6 +116,17 @@ export const PromptFullscreenModal = ({ > {children} + {showCounter && ( +
+ + {promptLength} / {isFinite(maxPromptLength) ? maxPromptLength : "∞"} + +
+ )} {imagePromptRow &&
{imagePromptRow}
}
{footerControls}
diff --git a/frontend/apps/artcraft-webapp/src/pages/create-image/create-image.tsx b/frontend/apps/artcraft-webapp/src/pages/create-image/create-image.tsx index 6a14e870ae..fe52b7a710 100644 --- a/frontend/apps/artcraft-webapp/src/pages/create-image/create-image.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/create-image/create-image.tsx @@ -121,6 +121,9 @@ export default function CreateImage() { return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0]; }, [apiModels, ui.selectedModelId]); + // Soft prompt limit from the API; undefined (no model / unset) = unlimited. + const maxPromptLength = selectedModel?.text_prompt_max_length ?? undefined; + const prompt = ui.prompt; const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]); @@ -363,6 +366,13 @@ export default function CreateImage() { } if (!prompt.trim() || isGenerating || !selectedModel) return; + if (maxPromptLength !== undefined && prompt.length > maxPromptLength) { + toast.error( + `Prompt exceeds the ${maxPromptLength} character limit for this model`, + ); + return; + } + setIsGenerating(true); const batchId = startBatch( prompt, @@ -435,6 +445,7 @@ export default function CreateImage() { prompt, isGenerating, selectedModel, + maxPromptLength, numImages, aspectRatio, resolution, @@ -618,6 +629,7 @@ export default function CreateImage() { onSubmit={handleGenerate} isSubmitting={isGenerating} credits={estimatedCredits} + maxPromptLength={maxPromptLength} placeholder="Describe what you want in the image..." supportsImagePrompts={!!selectedModel?.image_refs_supported} maxImagePromptCount={maxImageRefs} diff --git a/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx b/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx index 304bef076f..90dd7b6562 100644 --- a/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx @@ -62,6 +62,7 @@ import { OMNI_GENERATE_OUTAGE_MESSAGE, } from "@storyteller/omni-gen"; import { + effectivePromptMaxLength, getCreatorIconPathForModelId, getModelDescription, getModelInfo, @@ -273,6 +274,18 @@ export default function CreateVideo() { const requiresImageInput = selectedModel?.text_to_video_supported === false; + // Soft prompt limit from the API; undefined = unlimited. Seedance waives + // the limit (Infinity) when the prompt contains Chinese characters: the + // server counts CJK as more than one unit, so a client-side character + // count would false-positive. + const maxPromptLength = selectedModel + ? effectivePromptMaxLength( + selectedModel.model, + selectedModel.text_prompt_max_length ?? undefined, + ui.prompt, + ) + : undefined; + const prompt = ui.prompt; const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]); @@ -877,6 +890,12 @@ export default function CreateVideo() { }); return; } + if (maxPromptLength !== undefined && prompt.length > maxPromptLength) { + toast.error( + `Prompt exceeds the ${maxPromptLength} character limit for this model`, + ); + return; + } console.log("[generate-video] starting", { model: selectedModel.model, numVideos, @@ -1036,6 +1055,7 @@ export default function CreateVideo() { needsImage, isReferenceMode, selectedModel, + maxPromptLength, selectedSize, numVideos, effectiveDuration, @@ -1364,6 +1384,7 @@ export default function CreateVideo() { onSubmit={handleGenerate} isSubmitting={isGenerating} credits={estimatedCredits} + maxPromptLength={maxPromptLength} placeholder="Describe the video you want to generate..." supportsImagePrompts={supportsImagePrompts} maxImagePromptCount={ diff --git a/frontend/apps/artcraft-webapp/src/styles.css b/frontend/apps/artcraft-webapp/src/styles.css index f6c12154e0..ee9813fe57 100644 --- a/frontend/apps/artcraft-webapp/src/styles.css +++ b/frontend/apps/artcraft-webapp/src/styles.css @@ -271,6 +271,43 @@ background-color: rgba(255, 255, 255, 0.35); } +/* Hide the native resize grip (Chromium draws different grips for textareas + vs contenteditable divs) and draw one consistent grip on the wrapper so the + plain and @-mention prompt editors look identical. */ +.promptbox-scrollbar::-webkit-resizer { + background: transparent; + border: none; +} + +.promptbox-resize-wrap { + position: relative; +} + +.promptbox-resize-wrap::after { + content: ""; + position: absolute; + bottom: 2px; + right: 2px; + width: 10px; + height: 10px; + pointer-events: none; + background: linear-gradient( + 135deg, + transparent 40%, + rgba(255, 255, 255, 0.5) 40%, + rgba(255, 255, 255, 0.5) 45%, + transparent 45%, + transparent 55%, + rgba(255, 255, 255, 0.5) 55%, + rgba(255, 255, 255, 0.5) 60%, + transparent 60%, + transparent 70%, + rgba(255, 255, 255, 0.5) 70%, + rgba(255, 255, 255, 0.5) 75%, + transparent 75% + ); +} + @keyframes fade-in-up { from { opacity: 0; diff --git a/frontend/apps/artcraft-website/src/components/lightbox/LightboxDetails.tsx b/frontend/apps/artcraft-website/src/components/lightbox/LightboxDetails.tsx index 40c99438d5..67f2606c23 100644 --- a/frontend/apps/artcraft-website/src/components/lightbox/LightboxDetails.tsx +++ b/frontend/apps/artcraft-website/src/components/lightbox/LightboxDetails.tsx @@ -25,7 +25,7 @@ import { THUMBNAIL_SIZES, } from "@storyteller/common"; import { - getModelCreatorIcon, + getCreatorIconPathForModelId, getModelDisplayName, getProviderDisplayName, getProviderIconByName, @@ -297,7 +297,13 @@ export function LightboxDetails({ label="Model" value={ <> - {getModelCreatorIcon(promptData.modelType)} + Model creator logo {getModelDisplayName(promptData.modelType)} diff --git a/frontend/apps/artcraft-website/src/components/prompt-box/PromptBox.tsx b/frontend/apps/artcraft-website/src/components/prompt-box/PromptBox.tsx index 7dfde110d6..890ecab98b 100644 --- a/frontend/apps/artcraft-website/src/components/prompt-box/PromptBox.tsx +++ b/frontend/apps/artcraft-website/src/components/prompt-box/PromptBox.tsx @@ -109,6 +109,11 @@ interface PromptBoxProps { // @-mention support (enables colored prompt overlay + autocomplete) mentionItems?: MentionItem[]; + + // Soft prompt-length limit from the model API (`text_prompt_max_length`). + // Undefined = unlimited (no counter). The limit is not enforced here; the + // page's submit handler blocks generation when over. + maxPromptLength?: number; } export const PromptBox = forwardRef( @@ -139,6 +144,7 @@ export const PromptBox = forwardRef( mediaReferenceRow, modelSelector, mentionItems, + maxPromptLength, }, ref, ) => { @@ -423,7 +429,7 @@ export const PromptBox = forwardRef( )} -
+
{hasMentionItems && mentionItems ? ( ( )} )} + + {maxPromptLength !== undefined && ( +
maxPromptLength + ? "text-red-500" + : "text-base-fg/40", + )} + > + {prompt.length} /{" "} + {isFinite(maxPromptLength) ? maxPromptLength : "∞"} +
+ )}
diff --git a/frontend/apps/artcraft-website/src/pages/create-image/create-image.tsx b/frontend/apps/artcraft-website/src/pages/create-image/create-image.tsx index 094bc3dcf9..167591e4e5 100644 --- a/frontend/apps/artcraft-website/src/pages/create-image/create-image.tsx +++ b/frontend/apps/artcraft-website/src/pages/create-image/create-image.tsx @@ -20,6 +20,7 @@ import { } from "../../components/generation-gallery"; import { Lightbox } from "../../components/lightbox/lightbox"; import { AppDownloadCta } from "../../components/app-download-cta/AppDownloadCta"; +import { toast } from "../../components/toast/toast"; import { useCreateImageStore } from "./create-image-store"; import { enqueueImageGeneration, startPolling } from "./generate-image-api"; import { AspectRatioPicker } from "./components/AspectRatioPicker"; @@ -83,6 +84,9 @@ export default function CreateImage() { return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0]; }, [apiModels, ui.selectedModelId]); + // Soft prompt limit from the API; undefined (no model / unset) = unlimited. + const maxPromptLength = selectedModel?.text_prompt_max_length ?? undefined; + const prompt = ui.prompt; const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]); const aspectRatio = ui.aspectRatio; @@ -278,6 +282,13 @@ export default function CreateImage() { const handleGenerate = useCallback(async () => { if (!prompt.trim() || isGenerating || !selectedModel) return; + if (maxPromptLength !== undefined && prompt.length > maxPromptLength) { + toast.error( + `Prompt exceeds the ${maxPromptLength} character limit for this model`, + ); + return; + } + setIsGenerating(true); const batchId = startBatch( prompt, @@ -337,6 +348,7 @@ export default function CreateImage() { prompt, isGenerating, selectedModel, + maxPromptLength, numImages, aspectRatio, resolution, @@ -396,6 +408,7 @@ export default function CreateImage() { onSubmit={handleGenerate} isSubmitting={isGenerating} credits={estimatedCredits} + maxPromptLength={maxPromptLength} placeholder="Describe what you want in the image..." supportsImagePrompts={!!selectedModel?.image_refs_supported} maxImagePromptCount={maxImageRefs} diff --git a/frontend/apps/artcraft-website/src/pages/create-video/create-video.tsx b/frontend/apps/artcraft-website/src/pages/create-video/create-video.tsx index a8ed54c4bf..46f996f600 100644 --- a/frontend/apps/artcraft-website/src/pages/create-video/create-video.tsx +++ b/frontend/apps/artcraft-website/src/pages/create-video/create-video.tsx @@ -46,7 +46,11 @@ import { import { GenerationCountPicker } from "../create-image/components/GenerationCountPicker"; import { useVideoCostEstimate } from "../../lib/cost-estimate-api"; import { useOmniGenVideoModels } from "@storyteller/omni-gen"; -import { getCreatorIconPathForModelId } from "@storyteller/model-list"; +import { + effectivePromptMaxLength, + getCreatorIconPathForModelId, +} from "@storyteller/model-list"; +import { toast } from "../../components/toast/toast"; // ── Constants ──────────────────────────────────────────────────────────── @@ -182,6 +186,18 @@ export default function CreateVideo() { return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0]; }, [apiModels, ui.selectedModelId]); + // Soft prompt limit from the API; undefined = unlimited. Seedance waives + // the limit (Infinity) when the prompt contains Chinese characters: the + // server counts CJK as more than one unit, so a client-side character + // count would false-positive. + const maxPromptLength = selectedModel + ? effectivePromptMaxLength( + selectedModel.model, + selectedModel.text_prompt_max_length ?? undefined, + ui.prompt, + ) + : undefined; + const prompt = ui.prompt; const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]); const selectedSize = ui.selectedSize; @@ -717,6 +733,12 @@ export default function CreateVideo() { }); return; } + if (maxPromptLength !== undefined && prompt.length > maxPromptLength) { + toast.error( + `Prompt exceeds the ${maxPromptLength} character limit for this model`, + ); + return; + } console.log("[generate-video] starting", { model: selectedModel.model, numVideos, @@ -852,6 +874,7 @@ export default function CreateVideo() { needsImage, isReferenceMode, selectedModel, + maxPromptLength, selectedSize, numVideos, duration, @@ -935,6 +958,7 @@ export default function CreateVideo() { onSubmit={handleGenerate} isSubmitting={isGenerating || needsImage} credits={estimatedCredits} + maxPromptLength={maxPromptLength} placeholder="Describe the video you want to generate..." supportsImagePrompts={supportsImagePrompts} maxImagePromptCount={ diff --git a/frontend/apps/artcraft-website/src/styles.css b/frontend/apps/artcraft-website/src/styles.css index 39f3d4ea17..c5ffa988af 100644 --- a/frontend/apps/artcraft-website/src/styles.css +++ b/frontend/apps/artcraft-website/src/styles.css @@ -120,6 +120,43 @@ background-color: rgba(255, 255, 255, 0.35); } +/* Hide the native resize grip (Chromium draws different grips for textareas + vs contenteditable divs) and draw one consistent grip on the wrapper so the + plain and @-mention prompt editors look identical. */ +.promptbox-scrollbar::-webkit-resizer { + background: transparent; + border: none; +} + +.promptbox-resize-wrap { + position: relative; +} + +.promptbox-resize-wrap::after { + content: ""; + position: absolute; + bottom: 2px; + right: 2px; + width: 10px; + height: 10px; + pointer-events: none; + background: linear-gradient( + 135deg, + transparent 40%, + rgba(255, 255, 255, 0.5) 40%, + rgba(255, 255, 255, 0.5) 45%, + transparent 45%, + transparent 55%, + rgba(255, 255, 255, 0.5) 55%, + rgba(255, 255, 255, 0.5) 60%, + transparent 60%, + transparent 70%, + rgba(255, 255, 255, 0.5) 70%, + rgba(255, 255, 255, 0.5) 75%, + transparent 75% + ); +} + @keyframes fade-in-up { from { opacity: 0; diff --git a/frontend/libs/api/src/lib/OmniGenApi.ts b/frontend/libs/api/src/lib/OmniGenApi.ts index 491201f83f..caf9b2fa6a 100644 --- a/frontend/libs/api/src/lib/OmniGenApi.ts +++ b/frontend/libs/api/src/lib/OmniGenApi.ts @@ -77,6 +77,8 @@ export interface OmniGenVideoGenerateResponse { export interface OmniGenImageModelInfo { model: string; + // ModelCreator enum as a snake_case string, e.g. "bytedance", "google". + model_creator?: string | null; is_disabled: boolean | null; full_name: string | null; extra_info_short?: string | null; @@ -111,6 +113,8 @@ export interface OmniGenImageModelsResponse { export interface OmniGenVideoModelInfo { model: string; + // ModelCreator enum as a snake_case string, e.g. "bytedance", "open_ai". + model_creator?: string | null; is_disabled: boolean | null; full_name: string | null; extra_info_short?: string | null; diff --git a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx index 7753c7f921..b5cf53e1c2 100644 --- a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx +++ b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx @@ -43,7 +43,7 @@ import { isActionReminderOpen, } from "@storyteller/ui-action-reminder-modal"; import { - getModelCreatorIcon, + getCreatorIconPathForModelId, getModelDisplayName, getProviderDisplayName, getProviderIconByName, @@ -1327,7 +1327,11 @@ function InfoSection({ label="Model" value={ <> - {getModelCreatorIcon(modelType)} + Model creator logo {getModelDisplayName(modelType)} } diff --git a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx index ffc724f7db..45bff2e3a1 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx @@ -19,6 +19,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal"; import { CommonResolution, + effectivePromptMaxLength, SizeIconOption, SizeOption, VideoModel, @@ -697,7 +698,12 @@ export const PromptBoxVideo = ({ mentionAnchorRef.current = null; }; - const maxLen = selectedModel?.maxPromptLength ?? 1000; + const maxLen = + effectivePromptMaxLength( + selectedModel?.tauriId ?? "", + selectedModel?.maxPromptLength, + prompt, + ) ?? 1000; const handleEnqueue = async () => { if (!prompt.trim()) { diff --git a/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts b/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts index 26c9da3485..3643827aa4 100644 --- a/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts +++ b/frontend/libs/model-list/src/lib/classes/metadata/ModelMapping.ts @@ -2,165 +2,31 @@ import React from "react"; import { ModelCreator } from "./ModelCreator.js"; import { getCreatorIcon } from "./ModelCreatorIcons.js"; -// Map from model type strings to ModelCreator enum values -export const MODEL_TYPE_TO_CREATOR: Record = { - flux_1_dev: ModelCreator.BlackForestLabs, - flux_1_schnell: ModelCreator.BlackForestLabs, - flux_pro_1p1: ModelCreator.BlackForestLabs, - flux_pro_1p1_ultra: ModelCreator.BlackForestLabs, - flux_pro_kontext_max: ModelCreator.BlackForestLabs, - flux_dev_juggernaut: ModelCreator.BlackForestLabs, - flux_pro_1: ModelCreator.BlackForestLabs, - // Aliases commonly returned by other services - flux_pro_1_1: ModelCreator.BlackForestLabs, - flux_pro_1_1_ultra: ModelCreator.BlackForestLabs, - gpt_image_1: ModelCreator.OpenAi, - gpt_image_1p5: ModelCreator.OpenAi, - gpt_image_2: ModelCreator.OpenAi, - // Kling — p-style IDs - kling_1p6_pro: ModelCreator.Kling, - kling_2p1_pro: ModelCreator.Kling, - kling_2p1_master: ModelCreator.Kling, - kling_2p5_turbo_pro: ModelCreator.Kling, - kling_2p6_pro: ModelCreator.Kling, - kling_3p0_standard: ModelCreator.Kling, - kling_3p0_pro: ModelCreator.Kling, - // Kling — dot-normalized aliases (backend sends e.g. "kling_1.6_pro", normalizeModelKey converts . → _) - kling_1_6_pro: ModelCreator.Kling, - kling_2_1_pro: ModelCreator.Kling, - kling_2_1_master: ModelCreator.Kling, - // Seedance — p-style IDs - seedance_1p0_lite: ModelCreator.Bytedance, - seedance_1p5_pro: ModelCreator.Bytedance, - seedance_2p0: ModelCreator.Bytedance, - seedance_2p0_fast: ModelCreator.Bytedance, - seedance_2p0_bp: ModelCreator.Bytedance, - seedance_2p0_bp_fast: ModelCreator.Bytedance, - seedance_2p0_u: ModelCreator.Bytedance, - seedance_2p0_u_fast: ModelCreator.Bytedance, - seedance_2p0_bpu: ModelCreator.Bytedance, - seedance_2p0_bpu_fast: ModelCreator.Bytedance, - // Seedance — dot-normalized aliases - seedance_1_0_lite: ModelCreator.Bytedance, - // Seedream - seedream_4: ModelCreator.Bytedance, - seedream_4p5: ModelCreator.Bytedance, - seedream_5_lite: ModelCreator.Bytedance, - // Sora - sora_2: ModelCreator.OpenAi, - sora_2_pro: ModelCreator.OpenAi, - // Veo - veo_2: ModelCreator.Google, - veo_3: ModelCreator.Google, - veo_3_fast: ModelCreator.Google, - veo_3p1: ModelCreator.Google, - veo_3p1_fast: ModelCreator.Google, - gemini_25_flash: ModelCreator.Google, - nano_banana: ModelCreator.Google, - nano_banana_2: ModelCreator.Google, - nano_banana_pro: ModelCreator.Google, - recraft_3: ModelCreator.Recraft, - // Hunyuan — p-style IDs - hunyuan_3d: ModelCreator.Tencent, - hunyuan_3d_2p0: ModelCreator.Tencent, - hunyuan_3d_2p1: ModelCreator.Tencent, - hunyuan_3d_2: ModelCreator.Tencent, - hunyuan_3d_3: ModelCreator.Tencent, - // Hunyuan — dot-normalized aliases - hunyuan_3d_2_0: ModelCreator.Tencent, - hunyuan_3d_2_1: ModelCreator.Tencent, - worldlabs_gaussian: ModelCreator.WorldLabs, - marble_0p1_mini: ModelCreator.WorldLabs, - marble_0p1_plus: ModelCreator.WorldLabs, - grok_image: ModelCreator.Grok, - grok_imagine_image: ModelCreator.Grok, - grok_imagine_image_q: ModelCreator.Grok, - grok_video: ModelCreator.Grok, - grok_imagine_video: ModelCreator.Grok, - grok_imagine_video_1p5: ModelCreator.Grok, - midjourney: ModelCreator.Midjourney, - midjourney_v6: ModelCreator.Midjourney, - midjourney_v6p1: ModelCreator.Midjourney, - midjourney_v6p1_raw: ModelCreator.Midjourney, - midjourney_v7: ModelCreator.Midjourney, - midjourney_v7_raw: ModelCreator.Midjourney, - midjourney_v7_draft_raw: ModelCreator.Midjourney, - // Underscore-numbered IDs the backend actually sends for current MJ models - midjourney_7: ModelCreator.Midjourney, - midjourney_7_niji: ModelCreator.Midjourney, - midjourney_8: ModelCreator.Midjourney, - // Angles - flux_2_lora_angles: ModelCreator.BlackForestLabs, - qwen_edit_2511_angles: ModelCreator.Alibaba, - // Happy Horse (Alibaba wanvideo) - happy_horse_1p0: ModelCreator.Alibaba, - // Beeble (Background Change / VFX) - switch_x: ModelCreator.Beeble, -}; - -// Get creator icon for a model type +// Normalize a model id/type key for lookups (lowercase, dots → underscores) const normalizeModelKey = (modelType: string): string => modelType.toLowerCase().replace(/\./g, "_").trim(); -export const getModelCreatorIcon = ( - modelType: string, -): React.ReactNode | null => { - const creator = MODEL_TYPE_TO_CREATOR[normalizeModelKey(modelType)]; - if (!creator) return null; - return getCreatorIcon(creator, "h-4 w-4 invert"); -}; +/** Matches all seedance_* variants (video-only; image models are seedream_*). */ +export const isSeedanceVideoModelId = (modelId: string): boolean => + normalizeModelKey(modelId).startsWith("seedance"); -// Get creator name for display -export const getModelCreatorName = (modelType: string): string | null => { - const creator = MODEL_TYPE_TO_CREATOR[normalizeModelKey(modelType)]; +const CHINESE_CHARACTERS_REGEX = /\p{Script=Han}/u; - // Convert enum value to display name - switch (creator) { - case ModelCreator.BlackForestLabs: - return "Black Forest Labs"; - case ModelCreator.OpenAi: - return "OpenAI"; - case ModelCreator.Kling: - return "Kling AI"; - case ModelCreator.Bytedance: - return "ByteDance"; - case ModelCreator.Google: - return "Google"; - case ModelCreator.Midjourney: - return "Midjourney"; - case ModelCreator.Stability: - return "Stability AI"; - case ModelCreator.Runway: - return "Runway"; - case ModelCreator.Hailuo: - return "Hailuo"; - case ModelCreator.Recraft: - return "Recraft"; - case ModelCreator.Tencent: - return "Tencent"; - case ModelCreator.Alibaba: - return "Alibaba"; - case ModelCreator.Vidu: - return "Vidu"; - case ModelCreator.Fal: - return "Fal"; - case ModelCreator.Replicate: - return "Replicate"; - case ModelCreator.TensorArt: - return "TensorArt"; - case ModelCreator.OpenArt: - return "OpenArt"; - case ModelCreator.Higgsfield: - return "Higgsfield"; - case ModelCreator.Krea: - return "Krea"; - case ModelCreator.Grok: - return "Grok"; - case ModelCreator.WorldLabs: - return "World Labs"; - default: - return creator; +/** + * Effective soft prompt-length limit for a model. Normally the model's + * `text_prompt_max_length`, but seedance waives the limit (Infinity) when the + * prompt contains Chinese characters: the server counts CJK characters as + * more than one unit, so a client-side character count would false-positive. + */ +export const effectivePromptMaxLength = ( + modelId: string, + modelMaxLength: number | undefined, + prompt: string, +): number | undefined => { + if (isSeedanceVideoModelId(modelId) && CHINESE_CHARACTERS_REGEX.test(prompt)) { + return Infinity; } + return modelMaxLength; }; // Convert model type string to human-readable display name. An API-provided