webapp: grok imagine name/icon mapping, linked max duration for image… (#1562)
… ref mode
This commit is contained in:
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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user