Browse Source

feat: make GenerateVideoNode full-bleed

Video fills the entire node with no padding or chrome. Carousel
controls overlay at the bottom with backdrop blur (matching
GenerateImageNode pattern). Removed inline parameters since they
are displayed in the floating panel. Hidden ModelParameters kept
for schema-loading side effect (dynamic handles). All handles use
z-index to stay visible over content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
57d36896bd
  1. 110
      src/components/nodes/GenerateVideoNode.tsx

110
src/components/nodes/GenerateVideoNode.tsx

@ -18,11 +18,6 @@ import { getModelPageUrl, getProviderDisplayName } from "@/utils/providerUrls";
// Video generation capabilities
const VIDEO_CAPABILITIES: ModelCapability[] = ["text-to-video", "image-to-video"];
// Hardcoded Veo parameter options (matches getGeminiVideoSchema in models/[modelId]/route.ts)
const VEO_ASPECT_RATIOS = ["16:9", "9:16"] as const;
const VEO_DURATIONS = ["4", "6", "8"] as const;
const VEO_RESOLUTIONS = ["720p", "1080p", "4k"] as const;
/** Returns true for Gemini-native Veo video models */
function isVeoModel(modelId: string | undefined): boolean {
if (!modelId) return false;
@ -177,22 +172,6 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
[id, updateNodeData]
);
// Update a single key in the parameters bag (used by hardcoded Veo controls)
const updateVeoParam = useCallback(
(key: string, value: unknown) => {
const current = nodeData.parameters || {};
// Remove the key if value is empty string (clear optional fields)
if (value === "") {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [key]: _, ...rest } = current;
updateNodeData(id, { parameters: rest });
} else {
updateNodeData(id, { parameters: { ...current, [key]: value } });
}
},
[id, nodeData.parameters, updateNodeData]
);
// Handle inputs loaded from schema
const handleInputsLoaded = useCallback(
(inputs: ModelInputDef[]) => {
@ -415,6 +394,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
selected={selected}
isExecuting={isRunning}
hasError={nodeData.status === "error"}
fullBleed
>
{/* Dynamic input handles based on model schema */}
{nodeData.inputSchema && nodeData.inputSchema.length > 0 ? (
@ -513,6 +493,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
style={{
top: `${topPercent}%`,
opacity: handle.isPlaceholder ? 0.3 : 1,
zIndex: 10,
}}
data-handletype={handle.type}
data-schema-name={handle.schemaName || undefined}
@ -527,6 +508,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
top: `calc(${topPercent}% - 18px)`,
color: isImage ? "var(--handle-color-image)" : "var(--handle-color-text)",
opacity: handle.isPlaceholder ? 0.3 : 1,
zIndex: 10,
}}
>
{handle.label}
@ -569,7 +551,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
type="target"
position={Position.Left}
id="image"
style={{ top: "35%" }}
style={{ top: "35%", zIndex: 10 }}
data-handletype="image"
isConnectable={true}
/>
@ -580,6 +562,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
right: `calc(100% + 8px)`,
top: "calc(35% - 18px)",
color: "var(--handle-color-image)",
zIndex: 10,
}}
>
Image
@ -588,7 +571,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
type="target"
position={Position.Left}
id="text"
style={{ top: "65%" }}
style={{ top: "65%", zIndex: 10 }}
data-handletype="text"
/>
{/* Default text label */}
@ -598,6 +581,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
right: `calc(100% + 8px)`,
top: "calc(65% - 18px)",
color: "var(--handle-color-text)",
zIndex: 10,
}}
>
Prompt
@ -610,6 +594,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
position={Position.Right}
id="video"
data-handletype="video"
style={{ zIndex: 10 }}
/>
{/* Output label */}
<div
@ -618,16 +603,16 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
left: `calc(100% + 8px)`,
top: "calc(50% - 18px)",
color: "var(--handle-color-image)",
zIndex: 10,
}}
>
Video
</div>
<div className="flex-1 flex flex-col min-h-0 gap-2">
<div className="relative w-full h-full min-h-0">
{/* Preview area */}
{nodeData.outputVideo ? (
<>
<div className="relative w-full flex-1 min-h-0">
<video
key={nodeData.videoHistory?.[nodeData.selectedVideoHistoryIndex || 0]?.id}
src={videoBlobUrl ?? undefined}
@ -635,12 +620,12 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
autoPlay
loop
muted
className="w-full h-full object-contain rounded"
className="w-full h-full object-cover"
playsInline
/>
{/* Loading overlay for generation */}
{nodeData.status === "loading" && (
<div className="absolute inset-0 bg-neutral-900/70 rounded flex items-center justify-center">
<div className="absolute inset-0 bg-neutral-900/70 flex items-center justify-center">
<svg
className="w-6 h-6 animate-spin text-white"
fill="none"
@ -664,7 +649,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
)}
{/* Error overlay when generation failed */}
{nodeData.status === "error" && (
<div className="absolute inset-0 bg-red-900/40 rounded flex flex-col items-center justify-center gap-1">
<div className="absolute inset-0 bg-red-900/40 flex flex-col items-center justify-center gap-1">
<svg
className="w-6 h-6 text-white"
fill="none"
@ -680,7 +665,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
)}
{/* Loading overlay for carousel navigation */}
{isLoadingCarouselVideo && (
<div className="absolute inset-0 bg-neutral-900/50 rounded flex items-center justify-center">
<div className="absolute inset-0 bg-neutral-900/50 flex items-center justify-center">
<svg
className="w-4 h-4 animate-spin text-white"
fill="none"
@ -702,6 +687,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
</svg>
</div>
)}
{/* Clear button */}
<div className="absolute top-1 right-1">
<button
onClick={handleClearVideo}
@ -713,28 +699,27 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
</svg>
</button>
</div>
</div>
{/* Carousel controls - only show if there are multiple videos */}
{/* Carousel controls - overlaid on video bottom */}
{hasCarouselVideos && (
<div className="flex items-center justify-center gap-2 shrink-0">
<div className="absolute bottom-0 left-0 right-0 flex items-center justify-center gap-2 py-1.5 bg-neutral-900/60 backdrop-blur-sm">
<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"
className="w-5 h-5 rounded hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-white/70 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">
<span className="text-[10px] text-white/70 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"
className="w-5 h-5 rounded hover:bg-white/10 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center text-white/70 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}>
@ -745,7 +730,7 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
)}
</>
) : (
<div className="w-full flex-1 min-h-[112px] border border-dashed border-neutral-600 rounded flex flex-col items-center justify-center">
<div className="w-full h-full min-h-[112px] bg-neutral-900/40 flex flex-col items-center justify-center">
{nodeData.status === "loading" ? (
<svg
className="w-4 h-4 animate-spin text-neutral-400"
@ -777,52 +762,12 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
)}
</div>
)}
{/* Model-specific parameters */}
{nodeData.selectedModel?.modelId && isVeoModel(nodeData.selectedModel.modelId) ? (
// Hardcoded Veo parameters (matching GenerateImageNode pattern for Gemini models)
<div className="flex flex-col gap-1.5 shrink-0">
{/* Aspect ratio + Duration row */}
<div className="flex gap-1.5">
<select
value={(nodeData.parameters?.aspectRatio as string) || "16:9"}
onChange={(e) => updateVeoParam("aspectRatio", e.target.value)}
className="flex-1 text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300"
>
{VEO_ASPECT_RATIOS.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
<select
value={(nodeData.parameters?.durationSeconds as string) || "8"}
onChange={(e) => updateVeoParam("durationSeconds", e.target.value)}
className="w-12 text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300"
>
{VEO_DURATIONS.map((d) => (
<option key={d} value={d}>{d}s</option>
))}
</select>
<select
value={(nodeData.parameters?.resolution as string) || "720p"}
onChange={(e) => updateVeoParam("resolution", e.target.value)}
className="w-14 text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300"
>
{VEO_RESOLUTIONS.map((r) => (
<option key={r} value={r}>{r}</option>
))}
</select>
</div>
{/* Seed */}
<input
type="number"
placeholder="Seed (optional)"
value={(nodeData.parameters?.seed as string) ?? ""}
onChange={(e) => updateVeoParam("seed", e.target.value === "" ? "" : Number(e.target.value))}
min={0}
className="w-full text-[10px] py-1 px-1.5 border border-neutral-700 rounded bg-neutral-900/50 focus:outline-none focus:ring-1 focus:ring-neutral-600 text-neutral-300 placeholder:text-neutral-600"
/>
</div>
) : nodeData.selectedModel?.modelId ? (
</BaseNode>
{/* Hidden ModelParameters — only for schema-loading side effect (dynamic handles) */}
{nodeData.selectedModel?.modelId && !isVeoModel(nodeData.selectedModel.modelId) && (
<div className="hidden">
<ModelParameters
modelId={nodeData.selectedModel.modelId}
provider={currentProvider}
@ -831,9 +776,8 @@ export function GenerateVideoNode({ id, data, selected }: NodeProps<GenerateVide
onExpandChange={handleParametersExpandChange}
onInputsLoaded={handleInputsLoaded}
/>
) : null}
</div>
</BaseNode>
)}
{/* Model browser dialog */}
{isBrowseDialogOpen && (

Loading…
Cancel
Save