diff --git a/frontend/src/components/ImageManager.tsx b/frontend/src/components/ImageManager.tsx index d0b94ca6..f428b874 100644 --- a/frontend/src/components/ImageManager.tsx +++ b/frontend/src/components/ImageManager.tsx @@ -8,32 +8,53 @@ interface ImageManagerProps { images: string[]; onImagesChange: (images: string[]) => void; disabled?: boolean; + /** Maximum number of images allowed. When set to 1, replaces instead of adding. */ + maxImages?: number; + /** Label for the component */ + label?: string; + /** Tooltip for the label */ + tooltip?: string; + /** Hide the label */ + hideLabel?: boolean; } export function ImageManager({ images, onImagesChange, disabled, + maxImages, + label = "Reference Images", + tooltip = "Select reference images for VACE conditioning. Images will guide the video generation style and content.", + hideLabel = false, }: ImageManagerProps) { const [isMediaPickerOpen, setIsMediaPickerOpen] = useState(false); const handleAddImage = (imagePath: string) => { - onImagesChange([...images, imagePath]); + if (maxImages === 1) { + // Single image mode - replace + onImagesChange([imagePath]); + } else { + onImagesChange([...images, imagePath]); + } }; const handleRemoveImage = (index: number) => { onImagesChange(images.filter((_, i) => i !== index)); }; + const canAddMore = maxImages === undefined || images.length < maxImages; + return (
- + {!hideLabel && ( + + )} -
+
{images.length === 0 && (
))} + + {/* Show add button if we have images but can add more (multi-image mode) */} + {images.length > 0 && canAddMore && maxImages !== 1 && ( + + )}
void; onSendHints?: (imagePaths: string[]) => void; isDownloading?: boolean; + // FFLF (First-Frame-Last-Frame) extension mode + firstFrameImage?: string; + onFirstFrameImageChange?: (imagePath: string | undefined) => void; + lastFrameImage?: string; + onLastFrameImageChange?: (imagePath: string | undefined) => void; + extensionMode?: ExtensionMode; + onExtensionModeChange?: (mode: ExtensionMode) => void; + onSendExtensionFrames?: () => void; } export function InputAndControlsPanel({ @@ -115,6 +123,13 @@ export function InputAndControlsPanel({ onRefImagesChange, onSendHints, isDownloading = false, + firstFrameImage, + onFirstFrameImageChange, + lastFrameImage, + onLastFrameImageChange, + extensionMode = "firstframe", + onExtensionModeChange, + onSendExtensionFrames, }: InputAndControlsPanelProps) { // Helper function to determine if playhead is at the end of timeline const isAtEndOfTimeline = () => { @@ -326,6 +341,92 @@ export function InputAndControlsPanel({
)} + {/* FFLF Extension Frames - only show when VACE is enabled */} + {vaceEnabled && ( +
+ +
+
+ First Frame + { + onFirstFrameImageChange?.(images[0] || undefined); + }} + disabled={isDownloading} + maxImages={1} + hideLabel + /> +
+
+ Last Frame + { + onLastFrameImageChange?.(images[0] || undefined); + }} + disabled={isDownloading} + maxImages={1} + hideLabel + /> +
+
+ {(firstFrameImage || lastFrameImage) && ( +
+
+ Mode: + +
+
+ +
+
+ )} +
+ )} +
{(() => { // The Input can have two states: Append (default) and Edit (when a prompt is selected and the video is paused) diff --git a/frontend/src/hooks/useWebRTC.ts b/frontend/src/hooks/useWebRTC.ts index ad8eb520..9083f505 100644 --- a/frontend/src/hooks/useWebRTC.ts +++ b/frontend/src/hooks/useWebRTC.ts @@ -19,6 +19,8 @@ interface InitialParameters { kv_cache_attention_bias?: number; vace_ref_images?: string[]; vace_context_scale?: number; + first_frame_image?: string; + last_frame_image?: string; } interface UseWebRTCOptions { @@ -328,6 +330,8 @@ export function useWebRTC(options?: UseWebRTCOptions) { vace_ref_images?: string[]; vace_use_input_video?: boolean; vace_context_scale?: number; + first_frame_image?: string; + last_frame_image?: string; }) => { if ( dataChannelRef.current && diff --git a/frontend/src/pages/StreamPage.tsx b/frontend/src/pages/StreamPage.tsx index e5320142..b523f3ab 100644 --- a/frontend/src/pages/StreamPage.tsx +++ b/frontend/src/pages/StreamPage.tsx @@ -16,6 +16,7 @@ import { usePipelines } from "../hooks/usePipelines"; import { getDefaultPromptForMode } from "../data/pipelines"; import { adjustResolutionForPipeline } from "../lib/utils"; import type { + ExtensionMode, InputMode, PipelineId, LoRAConfig, @@ -524,6 +525,57 @@ export function StreamPage() { } }; + // Derive the appropriate extension mode based on which frame images are set + const deriveExtensionMode = ( + first: string | undefined, + last: string | undefined + ): ExtensionMode | undefined => { + if (first && last) return "firstlastframe"; + if (first) return "firstframe"; + if (last) return "lastframe"; + return undefined; + }; + + const handleFirstFrameImageChange = (imagePath: string | undefined) => { + updateSettings({ + firstFrameImage: imagePath, + extensionMode: deriveExtensionMode(imagePath, settings.lastFrameImage), + }); + }; + + const handleLastFrameImageChange = (imagePath: string | undefined) => { + updateSettings({ + lastFrameImage: imagePath, + extensionMode: deriveExtensionMode(settings.firstFrameImage, imagePath), + }); + }; + + const handleExtensionModeChange = (mode: ExtensionMode) => { + updateSettings({ extensionMode: mode }); + }; + + const handleSendExtensionFrames = () => { + const mode = settings.extensionMode || "firstframe"; + const params: Record = {}; + + if (mode === "firstframe" && settings.firstFrameImage) { + params.first_frame_image = settings.firstFrameImage; + } else if (mode === "lastframe" && settings.lastFrameImage) { + params.last_frame_image = settings.lastFrameImage; + } else if (mode === "firstlastframe") { + if (settings.firstFrameImage) { + params.first_frame_image = settings.firstFrameImage; + } + if (settings.lastFrameImage) { + params.last_frame_image = settings.lastFrameImage; + } + } + + if (Object.keys(params).length > 0) { + sendParameterUpdate(params); + } + }; + const handleResetCache = () => { // Send reset cache command to backend sendParameterUpdate({ @@ -809,6 +861,8 @@ export function StreamPage() { vace_ref_images?: string[]; vace_use_input_video?: boolean; vace_context_scale?: number; + first_frame_image?: string; + last_frame_image?: string; } = { // Signal the intended input mode to the backend so it doesn't // briefly fall back to text mode before video frames arrive @@ -850,6 +904,14 @@ export function StreamPage() { settings.vaceUseInputVideo ?? false; } + // Add FFLF (first-frame-last-frame) parameters if set + if (settings.firstFrameImage) { + initialParameters.first_frame_image = settings.firstFrameImage; + } + if (settings.lastFrameImage) { + initialParameters.last_frame_image = settings.lastFrameImage; + } + // Video mode parameters - applies to all pipelines in video mode if (currentMode === "video") { initialParameters.noise_scale = settings.noiseScale ?? 0.7; @@ -942,6 +1004,13 @@ export function StreamPage() { onRefImagesChange={handleRefImagesChange} onSendHints={handleSendHints} isDownloading={isDownloading} + firstFrameImage={settings.firstFrameImage} + onFirstFrameImageChange={handleFirstFrameImageChange} + lastFrameImage={settings.lastFrameImage} + onLastFrameImageChange={handleLastFrameImageChange} + extensionMode={settings.extensionMode || "firstframe"} + onExtensionModeChange={handleExtensionModeChange} + onSendExtensionFrames={handleSendExtensionFrames} />
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 932fd998..16a660c1 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -7,6 +7,9 @@ export type InputMode = "text" | "video"; // VAE type for model selection (dynamic from backend registry) export type VaeType = string; +// Extension mode for FFLF (First-Frame-Last-Frame) feature +export type ExtensionMode = "firstframe" | "lastframe" | "firstlastframe"; + // WebRTC ICE server configuration export interface IceServerConfig { urls: string | string[]; @@ -77,6 +80,10 @@ export interface SettingsState { vaceUseInputVideo?: boolean; refImages?: string[]; vaceContextScale?: number; + // FFLF (First-Frame-Last-Frame) extension mode + firstFrameImage?: string; + lastFrameImage?: string; + extensionMode?: ExtensionMode; // VAE type selection vaeType?: VaeType; }