webapp: grok imagine name/icon mapping, linked max duration for image… (#1562)

… ref mode
This commit is contained in:
BFlat authored and GitHub committed 2026-05-25 14:23:38 +07:00
1 parent c0ee2c44f6
commit 1d6d0ec1c3
5 files changed
+148 -116

No files matched your search

@@ -21,6 +21,8 @@ import {
} from "./upload-media";
interface MediaReferenceRowProps {
videoSupported: boolean;
audioSupported: boolean;
referenceVideos: RefVideo[];
onReferenceVideosChange: (videos: RefVideo[]) => void;
maxVideoCount: number;
@@ -33,6 +35,8 @@ interface MediaReferenceRowProps {
}
export const MediaReferenceRow = ({
videoSupported,
audioSupported,
referenceVideos,
onReferenceVideosChange,
maxVideoCount,
@@ -66,9 +70,7 @@ export const MediaReferenceRow = ({
totalAudioDuration < maxAudioRefDuration &&
!uploadingAudio;
const handleVideoUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const handleVideoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (videoInputRef.current) videoInputRef.current.value = "";
if (files.length === 0) return;
@@ -123,9 +125,7 @@ export const MediaReferenceRow = ({
}
};
const handleAudioUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
) => {
const handleAudioUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (audioInputRef.current) audioInputRef.current.value = "";
if (files.length === 0) return;
@@ -214,101 +214,107 @@ export const MediaReferenceRow = ({
onPointerDown={(e) => e.stopPropagation()}
>
{/* Video section */}
<div className="flex grow gap-2 px-3 py-2">
<div className="flex grow flex-col gap-1">
<div className="flex items-center gap-2 text-white/90">
<FontAwesomeIcon icon={faVideo} className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
Video Ref
<span className="font-semibold text-white/60">
({referenceVideos.length}/{maxVideoCount})
{videoSupported && (
<div className="flex grow gap-2 px-3 py-2">
<div className="flex grow flex-col gap-1">
<div className="flex items-center gap-2 text-white/90">
<FontAwesomeIcon icon={faVideo} className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
Video Ref
<span className="font-semibold text-white/60">
({referenceVideos.length}/{maxVideoCount})
</span>
</span>
</div>
<span className="text-[13px] text-white/60">
{totalVideoDuration}/{maxVideoRefDuration}s
</span>
</div>
<span className="text-[13px] text-white/60">
{totalVideoDuration}/{maxVideoRefDuration}s
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{referenceVideos.map((video) => (
<VideoRefTile
key={video.id}
video={video}
onRemove={removeVideo}
/>
))}
{uploadingVideo && (
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<FontAwesomeIcon
icon={faSpinnerThird}
spin
className="h-5 w-5 text-white/60"
<div className="flex flex-wrap items-center gap-2">
{referenceVideos.map((video) => (
<VideoRefTile
key={video.id}
video={video}
onRemove={removeVideo}
/>
</div>
)}
{canAddVideo && (
<button
onClick={() => videoInputRef.current?.click()}
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
>
<FontAwesomeIcon
icon={faPlus}
className="text-2xl text-white/80"
/>
</button>
)}
))}
{uploadingVideo && (
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<FontAwesomeIcon
icon={faSpinnerThird}
spin
className="h-5 w-5 text-white/60"
/>
</div>
)}
{canAddVideo && (
<button
onClick={() => videoInputRef.current?.click()}
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
>
<FontAwesomeIcon
icon={faPlus}
className="text-2xl text-white/80"
/>
</button>
)}
</div>
</div>
</div>
)}
<div className="h-[1px] sm:h-auto sm:w-[1px] self-stretch bg-white/10 mx-3 sm:mx-0" />
{videoSupported && audioSupported && (
<div className="h-[1px] sm:h-auto sm:w-[1px] self-stretch bg-white/10 mx-3 sm:mx-0" />
)}
{/* Audio section */}
<div className="flex grow gap-2 px-3 py-2">
<div className="flex grow flex-col gap-1">
<div className="flex items-center gap-2 text-white/90">
<FontAwesomeIcon icon={faMusic} className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
Audio Ref
<span className="font-semibold text-white/60">
({referenceAudios.length}/{maxAudioCount})
{audioSupported && (
<div className="flex grow gap-2 px-3 py-2">
<div className="flex grow flex-col gap-1">
<div className="flex items-center gap-2 text-white/90">
<FontAwesomeIcon icon={faMusic} className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
Audio Ref
<span className="font-semibold text-white/60">
({referenceAudios.length}/{maxAudioCount})
</span>
</span>
</div>
<span className="text-[13px] text-white/60">
{totalAudioDuration}/{maxAudioRefDuration}s
</span>
</div>
<span className="text-[13px] text-white/60">
{totalAudioDuration}/{maxAudioRefDuration}s
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{referenceAudios.map((audio, i) => (
<AudioRefTile
key={audio.id}
audio={audio}
index={i}
onRemove={removeAudio}
/>
))}
{uploadingAudio && (
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<FontAwesomeIcon
icon={faSpinnerThird}
spin
className="h-5 w-5 text-white/60"
<div className="flex flex-wrap items-center gap-2">
{referenceAudios.map((audio, i) => (
<AudioRefTile
key={audio.id}
audio={audio}
index={i}
onRemove={removeAudio}
/>
</div>
)}
{canAddAudio && (
<button
onClick={() => audioInputRef.current?.click()}
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
>
<FontAwesomeIcon
icon={faPlus}
className="text-2xl text-white/80"
/>
</button>
)}
))}
{uploadingAudio && (
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<FontAwesomeIcon
icon={faSpinnerThird}
spin
className="h-5 w-5 text-white/60"
/>
</div>
)}
{canAddAudio && (
<button
onClick={() => audioInputRef.current?.click()}
className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10"
>
<FontAwesomeIcon
icon={faPlus}
className="text-2xl text-white/80"
/>
</button>
)}
</div>
</div>
</div>
)}
</div>
</>
);
@@ -176,6 +176,7 @@ const MODEL_DISPLAY_NAMES: Record<string, string> = {
seedream_5_lite: "Seedream 5 Lite",
// Video models
grok_video: "Grok Video",
grok_imagine_video: "Grok Imagine",
kling_1p6_pro: "Kling 1.6 Pro",
kling_2p1_pro: "Kling 2.1 Pro",
kling_2p1_master: "Kling 2.1 Master",
@@ -159,22 +159,33 @@ function buildSizePopoverItems(
}));
}
// Effective max duration for the active input mode. Some models (e.g. Grok)
// cap duration lower when image references are used — that's reference mode here.
function maxDurationForMode(
model: OmniGenVideoModelInfo,
isReferenceMode: boolean,
): number | null {
const imageRefMax = model.duration_seconds_max_with_image_references;
return isReferenceMode && imageRefMax != null
? imageRefMax
: model.duration_seconds_max;
}
function resolveDurationForModel(
model: OmniGenVideoModelInfo,
current: number | null,
isReferenceMode: boolean,
): number | null {
if (current == null) return model.duration_seconds_default ?? null;
if (
model.duration_seconds_min != null &&
model.duration_seconds_max != null
) {
if (
current >= model.duration_seconds_min &&
current <= model.duration_seconds_max
) {
const max = maxDurationForMode(model, isReferenceMode);
if (model.duration_seconds_min != null && max != null) {
if (current >= model.duration_seconds_min && current <= max) {
return current;
}
return model.duration_seconds_default ?? model.duration_seconds_min;
return Math.min(
model.duration_seconds_default ?? model.duration_seconds_min,
max,
);
}
if (model.duration_seconds_options?.length) {
if (model.duration_seconds_options.includes(current)) return current;
@@ -316,10 +327,12 @@ export default function CreateVideo() {
!!selectedModel?.starting_keyframe_supported ||
!!selectedModel?.starting_keyframe_required ||
!!selectedModel?.image_references_supported;
const supportsVideoRefs = !!selectedModel?.video_references_supported;
const supportsAudioRefs = !!selectedModel?.audio_references_supported;
const supportsRefMode =
!!selectedModel?.image_references_supported ||
!!selectedModel?.video_references_supported ||
!!selectedModel?.audio_references_supported;
supportsVideoRefs ||
supportsAudioRefs;
const inputMode = ui.inputMode;
const isReferenceMode = supportsRefMode && inputMode === "reference";
const hasEndFrame = !!(
@@ -441,15 +454,13 @@ export default function CreateVideo() {
);
const durationRange = useMemo((): { min: number; max: number } | null => {
if (!selectedModel) return null;
const max = maxDurationForMode(selectedModel, isReferenceMode);
if (
selectedModel.duration_seconds_min != null &&
selectedModel.duration_seconds_max != null &&
selectedModel.duration_seconds_max > selectedModel.duration_seconds_min
max != null &&
max > selectedModel.duration_seconds_min
) {
return {
min: selectedModel.duration_seconds_min,
max: selectedModel.duration_seconds_max,
};
return { min: selectedModel.duration_seconds_min, max };
}
if (
selectedModel.duration_seconds_options &&
@@ -461,7 +472,7 @@ export default function CreateVideo() {
return { min: opts[0]!, max: opts[opts.length - 1]! };
}
return null;
}, [selectedModel]);
}, [selectedModel, isReferenceMode]);
const effectiveDuration =
duration ?? selectedModel?.duration_seconds_default ?? 5;
const [localDuration, setLocalDuration] = useState(effectiveDuration);
@@ -594,10 +605,6 @@ export default function CreateVideo() {
const model = item.action ? _modelLookup.get(item.action) : undefined;
if (!model) return;
const currentState = useCreateVideoStore.getState().ui;
const nextDuration = resolveDurationForModel(
model,
currentState.duration,
);
const newSupportsKeyframe =
!!model.starting_keyframe_supported ||
@@ -612,6 +619,12 @@ export default function CreateVideo() {
? "reference"
: "keyframe";
const nextDuration = resolveDurationForModel(
model,
currentState.duration,
nextInputMode === "reference",
);
setUi({
selectedModelId: model.model,
selectedSize: model.aspect_ratio_default ?? "wide_sixteen_by_nine",
@@ -659,15 +672,22 @@ export default function CreateVideo() {
(item: PopoverItem) => {
const mode = item.label === "Reference" ? "reference" : "keyframe";
if (mode === inputMode) return;
setUi({ inputMode: mode });
if (mode === "reference") {
// Some models (e.g. Grok) cap duration lower in image-reference mode.
const cap = selectedModel?.duration_seconds_max_with_image_references;
const clamped = cap != null && (duration ?? 0) > cap ? cap : undefined;
setUi({
inputMode: mode,
...(clamped != null ? { duration: clamped } : {}),
});
setEndFrameImage(undefined);
} else {
setUi({ inputMode: mode });
setReferenceVideos([]);
setReferenceAudios([]);
}
},
[inputMode, setUi],
[inputMode, duration, selectedModel, setUi],
);
const imagePickerMax = Math.max(
@@ -1030,8 +1050,10 @@ export default function CreateVideo() {
}
mentionItems={mentionItems.length > 0 ? mentionItems : undefined}
mediaReferenceRow={
isReferenceMode ? (
isReferenceMode && (supportsVideoRefs || supportsAudioRefs) ? (
<MediaReferenceRow
videoSupported={supportsVideoRefs}
audioSupported={supportsAudioRefs}
referenceVideos={referenceVideos}
onReferenceVideosChange={setReferenceVideos}
maxVideoCount={selectedModel?.video_references_max ?? 3}
+4 -3
View File
@@ -124,6 +124,9 @@ export interface OmniGenVideoModelInfo {
duration_seconds_default: number | null;
duration_seconds_min: number | null;
duration_seconds_max: number | null;
// Max duration when image references are used (e.g. Grok caps to 10s in
// reference mode while allowing the full duration_seconds_max in keyframe mode).
duration_seconds_max_with_image_references: number | null;
starting_keyframe_supported: boolean | null;
starting_keyframe_required: boolean | null;
ending_keyframe_supported: boolean | null;
@@ -165,9 +168,7 @@ export interface OmniGenProviderEntry {
/** Strip keys whose value is null or undefined so the server only sees
* fields that are explicitly set. */
function stripNulls(obj: object): Record<string, unknown> {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v != null),
);
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v != null));
}
// ── API class ────────────────────────────────────────────────────────────
@@ -68,6 +68,7 @@ export const MODEL_TYPE_TO_CREATOR: Record<string, ModelCreator> = {
marble_0p1_plus: ModelCreator.WorldLabs,
grok_image: ModelCreator.Grok,
grok_video: ModelCreator.Grok,
grok_imagine_video: ModelCreator.Grok,
midjourney: ModelCreator.Midjourney,
midjourney_v6: ModelCreator.Midjourney,
midjourney_v6p1: ModelCreator.Midjourney,
@@ -155,6 +156,7 @@ export const getModelDisplayName = (modelType: string): string => {
// Grok
grok_image: "Grok Image",
grok_video: "Grok Video",
grok_imagine_video: "Grok Imagine",
// Flux — Black Forest Labs
flux_1_dev: "Flux 1 Dev",