From ba8ef0bb79a4e89c285508f86f7e302269c5ca12 Mon Sep 17 00:00:00 2001
From: RyanOnTheInside <7623207+ryanontheinside@users.noreply.github.com>
Date: Fri, 9 Jan 2026 09:14:48 -0500
Subject: [PATCH] feat: add UI support for first-frame-last-frame (FFLF)
extension mode
Signed-off-by: RyanOnTheInside <7623207+ryanontheinside@users.noreply.github.com>
---
frontend/src/components/ImageManager.tsx | 49 +++++++--
.../src/components/InputAndControlsPanel.tsx | 103 +++++++++++++++++-
frontend/src/hooks/useWebRTC.ts | 4 +
frontend/src/pages/StreamPage.tsx | 69 ++++++++++++
frontend/src/types/index.ts | 7 ++
5 files changed, 223 insertions(+), 9 deletions(-)
diff --git a/frontend/src/components/ImageManager.tsx b/frontend/src/components/ImageManager.tsx
index d0b94ca6b..f428b8744 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 && (
+
setIsMediaPickerOpen(true)}
+ disabled={disabled}
+ className="aspect-square border-2 border-dashed rounded-lg flex flex-col items-center justify-center hover:bg-accent hover:border-accent-foreground disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+ >
+
+ Add Image
+
+ )}
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:
+
+
+
+
{
+ e.preventDefault();
+ onSendExtensionFrames?.();
+ }}
+ disabled={isDownloading || !isStreaming || (!firstFrameImage && !lastFrameImage)}
+ size="sm"
+ className="rounded-full w-8 h-8 p-0 bg-black hover:bg-gray-800 text-white disabled:opacity-50 disabled:cursor-not-allowed"
+ title={
+ !isStreaming
+ ? "Start streaming to send extension frames"
+ : "Send extension frames"
+ }
+ >
+
+
+
+
+ )}
+
+ )}
+
{(() => {
// 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 ad8eb520c..9083f505c 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 e5320142b..b523f3ab0 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 932fd998a..16a660c1e 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;
}