Browse Source

feat(09-01): add carousel controls to GenerateVideoNode

- Add isLoadingCarouselVideo state for loading indicator
- Add generationsPath from store for video loading
- Add loadVideoById callback to fetch videos from API
- Add carousel navigation handlers (handleCarouselPrevious/Next)
- Add hasCarouselVideos check for conditional display
- Add carousel UI with prev/next buttons and position indicator
- Add loading overlay during carousel navigation
- Update validation.ts with videoHistory fields

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
198319ec14
  1. 130
      src/components/nodes/GenerateVideoNode.tsx
  2. 2
      src/lib/quickstart/validation.ts

130
src/components/nodes/GenerateVideoNode.tsx

@ -19,10 +19,12 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const providerSettings = useWorkflowStore((state) => state.providerSettings);
const generationsPath = useWorkflowStore((state) => state.generationsPath);
const [externalModels, setExternalModels] = useState<ProviderModel[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelsFetchError, setModelsFetchError] = useState<string | null>(null);
const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false);
const [isLoadingCarouselVideo, setIsLoadingCarouselVideo] = useState(false);
// Get the current selected provider (default to fal since Gemini doesn't do video)
const currentProvider: ProviderType = nodeData.selectedModel?.provider || "fal";
@ -160,6 +162,77 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
regenerateNode(id);
}, [id, regenerateNode]);
// Load video by ID from generations folder
const loadVideoById = useCallback(async (videoId: string) => {
if (!generationsPath) {
console.error("Generations path not configured");
return null;
}
try {
const response = await fetch("/api/load-generation", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
directoryPath: generationsPath,
imageId: videoId,
}),
});
if (!response.ok) {
console.error("Failed to load video:", await response.text());
return null;
}
const result = await response.json();
return result.success ? (result.video || result.image) : null;
} catch (error) {
console.error("Error loading video:", error);
return null;
}
}, [generationsPath]);
// Carousel navigation handlers
const handleCarouselPrevious = useCallback(async () => {
const history = nodeData.videoHistory || [];
if (history.length === 0 || isLoadingCarouselVideo) return;
const currentIndex = nodeData.selectedVideoHistoryIndex || 0;
const newIndex = currentIndex === 0 ? history.length - 1 : currentIndex - 1;
const videoItem = history[newIndex];
setIsLoadingCarouselVideo(true);
const video = await loadVideoById(videoItem.id);
setIsLoadingCarouselVideo(false);
if (video) {
updateNodeData(id, {
outputVideo: video,
selectedVideoHistoryIndex: newIndex,
});
}
}, [id, nodeData.videoHistory, nodeData.selectedVideoHistoryIndex, isLoadingCarouselVideo, loadVideoById, updateNodeData]);
const handleCarouselNext = useCallback(async () => {
const history = nodeData.videoHistory || [];
if (history.length === 0 || isLoadingCarouselVideo) return;
const currentIndex = nodeData.selectedVideoHistoryIndex || 0;
const newIndex = (currentIndex + 1) % history.length;
const videoItem = history[newIndex];
setIsLoadingCarouselVideo(true);
const video = await loadVideoById(videoItem.id);
setIsLoadingCarouselVideo(false);
if (video) {
updateNodeData(id, {
outputVideo: video,
selectedVideoHistoryIndex: newIndex,
});
}
}, [id, nodeData.videoHistory, nodeData.selectedVideoHistoryIndex, isLoadingCarouselVideo, loadVideoById, updateNodeData]);
// Handle model selection from browse dialog
const handleBrowseModelSelect = useCallback((model: ProviderModel) => {
const newSelectedModel: SelectedModel = {
@ -189,6 +262,8 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
</button>
), []);
const hasCarouselVideos = (nodeData.videoHistory || []).length > 1;
// Track previous status to detect error transitions
const prevStatusRef = useRef(nodeData.status);
@ -324,6 +399,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
<div className="flex-1 flex flex-col min-h-0 gap-2">
{/* Preview area */}
{nodeData.outputVideo ? (
<>
<div className="relative w-full flex-1 min-h-0">
<video
src={nodeData.outputVideo}
@ -374,6 +450,30 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
<span className="text-white/70 text-[10px]">See toast for details</span>
</div>
)}
{/* Loading overlay for carousel navigation */}
{isLoadingCarouselVideo && (
<div className="absolute inset-0 bg-neutral-900/50 rounded flex items-center justify-center">
<svg
className="w-4 h-4 animate-spin text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="3"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
<div className="absolute top-1 right-1">
<button
onClick={handleClearVideo}
@ -386,6 +486,36 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
</button>
</div>
</div>
{/* Carousel controls - only show if there are multiple videos */}
{hasCarouselVideos && (
<div className="flex items-center justify-center gap-2 shrink-0">
<button
onClick={handleCarouselPrevious}
disabled={isLoadingCarouselVideo}
className="w-5 h-5 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Previous video"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-[10px] text-neutral-400 min-w-[32px] text-center">
{(nodeData.selectedVideoHistoryIndex || 0) + 1} / {(nodeData.videoHistory || []).length}
</span>
<button
onClick={handleCarouselNext}
disabled={isLoadingCarouselVideo}
className="w-5 h-5 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-neutral-400 hover:text-white transition-colors"
title="Next video"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
</>
) : (
<div className="w-full flex-1 min-h-[112px] border border-dashed border-neutral-600 rounded flex flex-col items-center justify-center">
{nodeData.status === "loading" ? (

2
src/lib/quickstart/validation.ts

@ -227,6 +227,8 @@ function createDefaultNodeData(type: NodeType): WorkflowNodeData {
selectedModel: undefined,
status: "idle",
error: null,
videoHistory: [],
selectedVideoHistoryIndex: 0,
};
case "llmGenerate":
return {

Loading…
Cancel
Save