diff --git a/frontend/apps/artcraft/app/src/pages/PageVideo/ImageToVideo.tsx b/frontend/apps/artcraft/app/src/pages/PageVideo/ImageToVideo.tsx index 03b77148c2..5809384bed 100644 --- a/frontend/apps/artcraft/app/src/pages/PageVideo/ImageToVideo.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageVideo/ImageToVideo.tsx @@ -210,9 +210,11 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => { uploadVideo={UploadVideoMedia} uploadAudio={UploadAudioMedia} credits={videoCredits} - onEnqueuePressed={async (prompt, subscriberId) => { + onEnqueuePressed={async (prompt, subscriberIds) => { const modelLabel = selectedVideoModel?.fullName ?? ""; - startBatch(prompt, modelLabel, subscriberId); + for (const subscriberId of subscriberIds) { + startBatch(prompt, modelLabel, subscriberId); + } }} /> diff --git a/frontend/libs/components/pricing-modal/src/lib/cost-breakdown-modal.tsx b/frontend/libs/components/pricing-modal/src/lib/cost-breakdown-modal.tsx index 876347a7f1..b11990deea 100644 --- a/frontend/libs/components/pricing-modal/src/lib/cost-breakdown-modal.tsx +++ b/frontend/libs/components/pricing-modal/src/lib/cost-breakdown-modal.tsx @@ -169,7 +169,7 @@ export function CostBreakdownModal({ activeTabId }: CostBreakdownModalProps) { case ModelPage.ImageToVideo: return { resolution: promptVideo.resolution, - generationCount: 1, // Video doesn't have generation count + generationCount: promptVideo.generationCount, label: "Videos", }; case ModelPage.ImageEditor: diff --git a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx index d9ecbc7bce..f502e25dac 100644 --- a/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx +++ b/frontend/libs/components/promptbox/src/lib/PromptBoxVideo.tsx @@ -35,6 +35,7 @@ import { gtagEvent } from "@storyteller/google-analytics"; import { ImagePromptRow } from "./ImagePromptRow"; import type { UploadImageFn } from "./ImagePromptRow"; import { AspectRatioIcon } from "./common/AspectRatioIcon"; +import { VideoGenerationCountPicker } from "./common/VideoGenerationCountPicker"; import { twMerge } from "tailwind-merge"; import { toast } from "@storyteller/ui-toaster"; import { GenerationProvider } from "@storyteller/api-enums"; @@ -58,7 +59,7 @@ interface PromptBoxVideoProps { useJobContext: () => JobContextType; onEnqueuePressed?: ( prompt: string, - subscriberId: string, + subscriberIds: string[], ) => void | Promise; selectedModel?: VideoModel; selectedProvider?: GenerationProvider; @@ -117,6 +118,8 @@ export const PromptBoxVideo = ({ const setDuration = usePromptVideoStore((s) => s.setDuration); const inputMode = usePromptVideoStore((s) => s.inputMode); const setInputMode = usePromptVideoStore((s) => s.setInputMode); + const generationCount = usePromptVideoStore((s) => s.generationCount); + const setGenerationCount = usePromptVideoStore((s) => s.setGenerationCount); const [isEnqueueing, setIsEnqueueing] = useState(false); const [isFocused, setIsFocused] = useState(false); const [selectedGalleryImages, setSelectedGalleryImages] = useState( @@ -232,6 +235,13 @@ export const PromptBoxVideo = ({ } }, [selectedModel]); + // Reset generation count when switching away from seedance 2.0 + useEffect(() => { + if (selectedModel?.id !== "seedance_2p0" && generationCount > 1) { + setGenerationCount(1); + } + }, [selectedModel]); + const durationRange = selectedModel?.durationOptions?.length ? { min: selectedModel.durationOptions[0]!, @@ -495,9 +505,8 @@ export const PromptBoxVideo = ({ gtagEvent("enqueue_video"); - const subscriberId = crypto.randomUUID - ? crypto.randomUUID() - : Math.random().toString(36).slice(2); + const isSeedance2 = selectedModel.id === "seedance_2p0"; + const count = isSeedance2 ? generationCount : 1; const isRefMode = inputMode === "reference" && !!selectedModel.supportsReferenceMode; @@ -515,83 +524,104 @@ export const PromptBoxVideo = ({ setIsEnqueueing(false); }, 10000); - let request: EnqueueImageToVideoRequest = { - model: selectedModel, - image_media_token: imageMediaToken, - prompt: prompt, - end_frame_image_media_token: isRefMode - ? undefined - : endFrameImage?.mediaToken, - frontend_caller: "image_to_video", - frontend_subscriber_id: subscriberId, - }; + const buildRequest = (subscriberId: string): EnqueueImageToVideoRequest => { + let request: EnqueueImageToVideoRequest = { + model: selectedModel, + image_media_token: imageMediaToken, + prompt: prompt, + end_frame_image_media_token: isRefMode + ? undefined + : endFrameImage?.mediaToken, + frontend_caller: "image_to_video", + frontend_subscriber_id: subscriberId, + }; - if (!!selectedProvider) { - request.provider = selectedProvider; - } + if (!!selectedProvider) { + request.provider = selectedProvider; + } - if (selectedModel.generateWithSound) { - request.generate_audio = !!generateWithSound; - } + if (selectedModel.generateWithSound) { + request.generate_audio = !!generateWithSound; + } - // Pass reference image tokens in reference mode - if (isRefMode && referenceImages.length > 0) { - request.reference_image_media_tokens = referenceImages.map( - (img) => img.mediaToken, - ); - } + // Pass reference image tokens in reference mode + if (isRefMode && referenceImages.length > 0) { + request.reference_image_media_tokens = referenceImages.map( + (img) => img.mediaToken, + ); + } - // Pass reference video tokens in reference mode - if (isRefMode && referenceVideos.length > 0) { - request.reference_video_media_tokens = referenceVideos.map( - (v) => v.mediaToken, - ); - } + // Pass reference video tokens in reference mode + if (isRefMode && referenceVideos.length > 0) { + request.reference_video_media_tokens = referenceVideos.map( + (v) => v.mediaToken, + ); + } - // Pass reference audio tokens in reference mode - if (isRefMode && referenceAudios.length > 0) { - request.reference_audio_media_tokens = referenceAudios.map( - (a) => a.mediaToken, - ); - } + // Pass reference audio tokens in reference mode + if (isRefMode && referenceAudios.length > 0) { + request.reference_audio_media_tokens = referenceAudios.map( + (a) => a.mediaToken, + ); + } - // Pass duration if model supports it - if (selectedModel.durationOptions && duration !== null) { - request.duration_seconds = duration; - } + // Pass duration if model supports it + if (selectedModel.durationOptions && duration !== null) { + request.duration_seconds = duration; + } - switch (selectedModel?.tauriId) { - case "grok_video": - request.grok_aspect_ratio = getGrokAspectRatio(); - break; + switch (selectedModel?.tauriId) { + case "grok_video": + request.grok_aspect_ratio = getGrokAspectRatio(); + break; - case "sora_2": - request.sora_orientation = - resolution === "720p" ? "landscape" : "portrait"; - break; - } + case "sora_2": + request.sora_orientation = + resolution === "720p" ? "landscape" : "portrait"; + break; + } - if (selectedModel.supportsCommonAspectRatio) { - // NB: This should be the *new* aspect ratio behavior for all models. - const selectedOption = selectedModel.sizeOptions?.find( - (option) => option.textLabel === aspectRatio, - ); + if (selectedModel.supportsCommonAspectRatio) { + const selectedOption = selectedModel.sizeOptions?.find( + (option) => option.textLabel === aspectRatio, + ); - if (selectedOption) { - request.aspect_ratio = - selectedOption.tauriValue as typeof request.aspect_ratio; - } else { - const maybeDefault = selectedModel.sizeOptions[0]; - if (!!maybeDefault) { + if (selectedOption) { request.aspect_ratio = - maybeDefault.tauriValue as typeof request.aspect_ratio; + selectedOption.tauriValue as typeof request.aspect_ratio; + } else { + const maybeDefault = selectedModel.sizeOptions[0]; + if (!!maybeDefault) { + request.aspect_ratio = + maybeDefault.tauriValue as typeof request.aspect_ratio; + } } } + + return request; + }; + + const subscriberIds: string[] = []; + const enqueuePromises: Promise[] = []; + + for (let i = 0; i < count; i++) { + const subscriberId = crypto.randomUUID + ? crypto.randomUUID() + : Math.random().toString(36).slice(2); + subscriberIds.push(subscriberId); + enqueuePromises.push( + EnqueueImageToVideo(buildRequest(subscriberId)) as Promise, + ); } - await EnqueueImageToVideo(request); + try { + await Promise.all(enqueuePromises); + } catch (err) { + console.error("PromptBoxVideo - enqueue failed", err); + toast.error("Failed to start video generation. Please try again."); + } - onEnqueuePressed?.(prompt, subscriberId); + onEnqueuePressed?.(prompt, subscriberIds); setIsEnqueueing(false); }; @@ -972,6 +1002,7 @@ export const PromptBoxVideo = ({ /> )} +
{modelNeedsAnImageButNoneAreSelected && ( @@ -980,6 +1011,13 @@ export const PromptBoxVideo = ({ Starting frame required )} + {selectedModel?.id === "seedance_2p0" && ( + + )} Generate diff --git a/frontend/libs/components/promptbox/src/lib/common/VideoGenerationCountPicker.tsx b/frontend/libs/components/promptbox/src/lib/common/VideoGenerationCountPicker.tsx new file mode 100644 index 0000000000..69194f89f0 --- /dev/null +++ b/frontend/libs/components/promptbox/src/lib/common/VideoGenerationCountPicker.tsx @@ -0,0 +1,50 @@ +import { faCopy } from "@fortawesome/pro-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover"; +import { Tooltip } from "@storyteller/ui-tooltip"; + +interface VideoGenerationCountPickerProps { + maxCount: number; + currentCount: number; + handleCountChange: (count: number) => void; +} + +export const VideoGenerationCountPicker = ({ + maxCount, + currentCount, + handleCountChange, +}: VideoGenerationCountPickerProps) => { + const options: PopoverItem[] = []; + for (let i = 1; i <= maxCount; i++) { + options.push({ + label: String(i), + selected: i === currentCount, + }); + } + + const onSelect = (item: PopoverItem) => { + const count = parseInt(item.label, 10); + if (!isNaN(count) && count >= 1 && count <= maxCount) { + handleCountChange(count); + } + }; + + return ( + + } + buttonClassName="h-9" + /> + + ); +}; diff --git a/frontend/libs/components/promptbox/src/lib/promptStore.ts b/frontend/libs/components/promptbox/src/lib/promptStore.ts index d0c457bf7b..182f0f5600 100644 --- a/frontend/libs/components/promptbox/src/lib/promptStore.ts +++ b/frontend/libs/components/promptbox/src/lib/promptStore.ts @@ -139,6 +139,7 @@ interface PromptVideoStore { generateWithSound: boolean; duration: number | null; inputMode: VideoInputMode; + generationCount: number; setPrompt: (prompt: string) => void; setResolution: (resolution: Resolution | string) => void; setAspectRatio: (aspectRatio: string | null) => void; @@ -150,6 +151,7 @@ interface PromptVideoStore { setGenerateWithSound: (value: boolean) => void; setDuration: (duration: number | null) => void; setInputMode: (mode: VideoInputMode) => void; + setGenerationCount: (count: number) => void; } export const usePromptVideoStore = create()((set) => ({ @@ -164,6 +166,7 @@ export const usePromptVideoStore = create()((set) => ({ generateWithSound: true, duration: null, inputMode: "keyframe", + generationCount: 1, setPrompt: (prompt) => set({ prompt }), setResolution: (resolution) => set({ resolution }), setAspectRatio: (aspectRatio) => set({ aspectRatio }), @@ -175,6 +178,7 @@ export const usePromptVideoStore = create()((set) => ({ setGenerateWithSound: (generateWithSound) => set({ generateWithSound }), setDuration: (duration) => set({ duration }), setInputMode: (inputMode) => set({ inputMode }), + setGenerationCount: (generationCount) => set({ generationCount }), })); // ----- Edit Prompt Box Store -----