@ -42,6 +42,8 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
const updateNodeData = useWorkflowStore ( ( state ) = > state . updateNodeData ) ;
const updateNodeData = useWorkflowStore ( ( state ) = > state . updateNodeData ) ;
const regenerateNode = useWorkflowStore ( ( state ) = > state . regenerateNode ) ;
const regenerateNode = useWorkflowStore ( ( state ) = > state . regenerateNode ) ;
const isRunning = useWorkflowStore ( ( state ) = > state . isRunning ) ;
const isRunning = useWorkflowStore ( ( state ) = > state . isRunning ) ;
const edges = useWorkflowStore ( ( state ) = > state . edges ) ;
const removeEdge = useWorkflowStore ( ( state ) = > state . removeEdge ) ;
const [ activeTab , setActiveTab ] = useState < "editor" | "video" > ( "editor" ) ;
const [ activeTab , setActiveTab ] = useState < "editor" | "video" > ( "editor" ) ;
const [ showPresets , setShowPresets ] = useState ( false ) ;
const [ showPresets , setShowPresets ] = useState ( false ) ;
@ -137,6 +139,25 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
regenerateNode ( id ) ;
regenerateNode ( id ) ;
} , [ id , regenerateNode ] ) ;
} , [ id , regenerateNode ] ) ;
// Check if this node has an incoming easeCurve connection (inheritance)
const inheritedEdge = useMemo ( ( ) = > {
return edges . find ( ( e ) = > e . target === id && e . targetHandle === "easeCurve" ) || null ;
} , [ edges , id ] ) ;
const isInherited = inheritedEdge !== null ;
const handleBreakInheritance = useCallback ( ( ) = > {
if ( inheritedEdge ) {
removeEdge ( inheritedEdge . id ) ;
updateNodeData ( id , { inheritedFrom : null } ) ;
}
} , [ inheritedEdge , removeEdge , id , updateNodeData ] ) ;
// Compute easing curve polyline for the editor overlay (higher sample count than thumbnails)
const editorEasingCurve = useMemo ( ( ) = > {
if ( ! nodeData . easingPreset ) return undefined ;
return generateEasingPolyline ( nodeData . easingPreset , 100 , 100 , 50 ) ;
} , [ nodeData . easingPreset ] ) ;
// Memoize the preset thumbnail SVGs
// Memoize the preset thumbnail SVGs
const presetThumbnails = useMemo ( ( ) = > {
const presetThumbnails = useMemo ( ( ) = > {
return ALL_EASING_NAMES . map ( ( name ) = > ( {
return ALL_EASING_NAMES . map ( ( name ) = > ( {
@ -146,25 +167,72 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
} ) ) ;
} ) ) ;
} , [ ] ) ;
} , [ ] ) ;
// Shared handles rendered in ALL states
// Shared handles rendered in ALL states (4 handles with labels)
const renderHandles = ( ) = > (
const renderHandles = ( ) = > (
< >
< >
{ /* Video In (target, left, 35%) */ }
< Handle
< Handle
type = "target"
type = "target"
position = { Position . Left }
position = { Position . Left }
id = "video"
id = "video"
data - handletype = "video"
data - handletype = "video"
isConnectable = { true }
isConnectable = { true }
style = { { top : "50 %" } }
style = { { top : "3 5%" } }
/ >
/ >
< div
className = "absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style = { { right : "calc(100% + 8px)" , top : "calc(35% - 7px)" , color : "rgb(168, 85, 247)" } }
>
Video In
< / div >
{ /* Video Out (source, right, 35%) */ }
< Handle
< Handle
type = "source"
type = "source"
position = { Position . Right }
position = { Position . Right }
id = "video"
id = "video"
data - handletype = "video"
data - handletype = "video"
isConnectable = { true }
isConnectable = { true }
style = { { top : "50%" } }
style = { { top : "3 5%" } }
/ >
/ >
< div
className = "absolute text-[10px] font-medium whitespace-nowrap pointer-events-none"
style = { { left : "calc(100% + 8px)" , top : "calc(35% - 7px)" , color : "rgb(168, 85, 247)" } }
>
Video Out
< / div >
{ /* Ease In (target, left, 75%) */ }
< Handle
type = "target"
position = { Position . Left }
id = "easeCurve"
data - handletype = "easeCurve"
isConnectable = { true }
style = { { top : "75%" , background : "rgb(190, 242, 100)" } }
/ >
< div
className = "absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style = { { right : "calc(100% + 8px)" , top : "calc(75% - 7px)" , color : "rgb(190, 242, 100)" } }
>
Ease In
< / div >
{ /* Ease Out (source, right, 75%) */ }
< Handle
type = "source"
position = { Position . Right }
id = "easeCurve"
data - handletype = "easeCurve"
isConnectable = { true }
style = { { top : "75%" , background : "rgb(190, 242, 100)" } }
/ >
< div
className = "absolute text-[10px] font-medium whitespace-nowrap pointer-events-none"
style = { { left : "calc(100% + 8px)" , top : "calc(75% - 7px)" , color : "rgb(190, 242, 100)" } }
>
Ease Out
< / div >
< / >
< / >
) ;
) ;
@ -278,153 +346,172 @@ export function EaseCurveNode({ id, data, selected }: NodeProps<EaseCurveNodeTyp
{ /* Tab content */ }
{ /* Tab content */ }
{ activeTab === "editor" && (
{ activeTab === "editor" && (
< div className = "flex-1 flex flex-col min-h-0 gap-2" >
< div className = "flex-1 flex flex-col min-h-0 gap-2 relative" >
{ /* Bezier curve editor - fills available width */ }
{ /* Editor controls - dimmed when inherited */ }
< div className = "flex-1 min-h-0 px-2" >
< div className = { isInherited ? "pointer-events-none opacity-40" : "" } >
< CubicBezierEditor
{ /* Bezier curve editor - fills available width */ }
value = { nodeData . bezierHandles }
< div className = "flex-1 min-h-0 px-2" >
onChange = { handleBezierChange }
< CubicBezierEditor
onCommit = { handleBezierCommit }
value = { nodeData . bezierHandles }
disabled = { nodeData . status === "loading" }
onChange = { handleBezierChange }
/ >
onCommit = { handleBezierCommit }
< / div >
disabled = { nodeData . status === "loading" || isInherited }
easingCurve = { editorEasingCurve }
{ /* Preset label */ }
/ >
{ nodeData . easingPreset && (
< div className = "text-center -mt-1" >
< span className = "text-[10px] text-lime-300/70 font-medium" >
{ nodeData . easingPreset }
< / span >
< / div >
< / div >
) }
{ /* Controls row: Duration + Presets button */ }
< div className = "flex items-center gap-2 px-2" >
< label className = "text-[10px] text-neutral-400 whitespace-nowrap" > Duration < / label >
< input
type = "number"
min = "0.1"
max = "30"
step = "0.1"
value = { nodeData . outputDuration }
onChange = { handleDurationChange }
className = "nodrag w-16 px-1.5 py-1 bg-neutral-800 border border-neutral-600 rounded text-xs text-neutral-200 text-center"
/ >
< span className = "text-[10px] text-neutral-500" > sec < / span >
{ /* Presets button */ }
< div className = "relative ml-auto" ref = { presetsRef } >
< button
className = "nodrag nopan px-2 py-1 bg-neutral-800 hover:bg-neutral-700 border border-neutral-600 rounded text-xs text-neutral-300 transition-colors flex items-center gap-1"
onClick = { ( ) = > setShowPresets ( ! showPresets ) }
>
{ /* Curve icon */ }
< svg className = "w-3 h-3" viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" strokeWidth = "1.5" >
< path d = "M2 14 C 6 14, 4 2, 14 2" strokeLinecap = "round" / >
< / svg >
< span > Presets < / span >
< / button >
{ /* Presets popover */ }
{ /* Preset label */ }
{ showPresets && (
{ nodeData . easingPreset && (
< div className = "absolute z-50 right-0 bottom-full mb-1 w-[280px] bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-2 nowheel" >
< div className = "text-center -mt-1" >
{ /* Preset Bezier thumbnails (top section) */ }
< span className = "text-[10px] text-lime-300/70 font-medium" >
< div className = "mb-2" >
{ nodeData . easingPreset }
< div className = "text-[9px] text-neutral-500 uppercase tracking-wider mb-1 px-1" >
< / span >
Bezier Presets
< / div >
) }
{ /* Controls row: Duration + Presets button */ }
< div className = "flex items-center gap-2 px-2" >
< label className = "text-[10px] text-neutral-400 whitespace-nowrap" > Duration < / label >
< input
type = "number"
min = "0.1"
max = "30"
step = "0.1"
value = { nodeData . outputDuration }
onChange = { handleDurationChange }
className = "nodrag w-16 px-1.5 py-1 bg-neutral-800 border border-neutral-600 rounded text-xs text-neutral-200 text-center"
/ >
< span className = "text-[10px] text-neutral-500" > sec < / span >
{ /* Presets button */ }
< div className = "relative ml-auto" ref = { presetsRef } >
< button
className = "nodrag nopan px-2 py-1 bg-neutral-800 hover:bg-neutral-700 border border-neutral-600 rounded text-xs text-neutral-300 transition-colors flex items-center gap-1 disabled:opacity-40 disabled:pointer-events-none"
onClick = { ( ) = > setShowPresets ( ! showPresets ) }
disabled = { isInherited }
>
{ /* Curve icon */ }
< svg className = "w-3 h-3" viewBox = "0 0 16 16" fill = "none" stroke = "currentColor" strokeWidth = "1.5" >
< path d = "M2 14 C 6 14, 4 2, 14 2" strokeLinecap = "round" / >
< / svg >
< span > Presets < / span >
< / button >
{ /* Presets popover */ }
{ showPresets && (
< div className = "absolute z-50 right-0 bottom-full mb-1 w-[280px] bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-2 nowheel" >
{ /* Preset Bezier thumbnails (top section) */ }
< div className = "mb-2" >
< div className = "text-[9px] text-neutral-500 uppercase tracking-wider mb-1 px-1" >
Bezier Presets
< / div >
< div className = "grid grid-cols-5 gap-1" >
{ EASING_PRESETS . map ( ( name ) = > {
const thumb = presetThumbnails . find ( ( t ) = > t . name === name ) ;
const isActive = nodeData . easingPreset === name ;
return (
< button
key = { name }
className = { ` nodrag nopan flex flex-col items-center gap-0.5 p-1 rounded transition-colors ${
isActive
? "bg-lime-300/20 border border-lime-300/40"
: "hover:bg-neutral-700 border border-transparent"
} ` }
onClick = { ( ) = > handleSelectPreset ( name ) }
title = { name }
>
< svg width = "40" height = "40" viewBox = "-2 -2 40 40" className = "flex-shrink-0" >
< rect x = "-2" y = "-2" width = "40" height = "40" fill = "transparent" / >
< line x1 = "0" y1 = "36" x2 = "36" y2 = "0" stroke = "rgba(255,255,255,0.1)" strokeWidth = "0.5" strokeDasharray = "2 2" / >
{ thumb && (
< polyline
points = { thumb . polyline }
fill = "none"
stroke = { isActive ? "#bef264" : "rgba(255,255,255,0.5)" }
strokeWidth = "1.5"
strokeLinecap = "round"
strokeLinejoin = "round"
/ >
) }
< / svg >
< span className = "text-[7px] text-neutral-400 truncate w-full text-center leading-none" >
{ name . replace ( /^ease/ , "" ) }
< / span >
< / button >
) ;
} ) }
< / div >
< / div >
< / div >
< div className = "grid grid-cols-5 gap-1" >
{ EASING_PRESETS . map ( ( name ) = > {
{ /* All easing functions (scrollable grid) */ }
const thumb = presetThumbnails . find ( ( t ) = > t . name === name ) ;
< div className = "border-t border-neutral-700 pt-2" >
const isActive = nodeData . easingPreset === name ;
< div className = "text-[9px] text-neutral-500 uppercase tracking-wider mb-1 px-1" >
return (
All Easing Functions
< button
< / div >
key = { name }
< div className = "grid grid-cols-5 gap-1 max-h-[200px] overflow-y-auto nowheel" >
className = { ` nodrag nopan flex flex-col items-center gap-0.5 p-1 rounded transition-colors ${
{ presetThumbnails . map ( ( { name , polyline } ) = > {
isActive
const isActive = nodeData . easingPreset === name ;
? "bg-lime-300/20 border border-lime-300/40"
return (
: "hover:bg-neutral-700 border border-transparent"
< button
} ` }
key = { name }
onClick = { ( ) = > handleSelectPreset ( name ) }
className = { ` nodrag nopan flex flex-col items-center gap-0.5 p-1 rounded transition-colors ${
title = { name }
isActive
>
? "bg-lime-300/20 border border-lime-300/40"
< svg width = "40" height = "40" viewBox = "-2 -2 40 40" className = "flex-shrink-0" >
: "hover:bg-neutral-700 border border-transparent"
< rect x = "-2" y = "-2" width = "40" height = "40" fill = "transparent" / >
} ` }
< line x1 = "0" y1 = "36" x2 = "36" y2 = "0" stroke = "rgba(255,255,255,0.1)" strokeWidth = "0.5" strokeDasharray = "2 2" / >
onClick = { ( ) = > handleSelectEasing ( name ) }
{ thumb && (
title = { name }
>
< svg width = "40" height = "40" viewBox = "-2 -2 40 40" className = "flex-shrink-0" >
< rect x = "-2" y = "-2" width = "40" height = "40" fill = "transparent" / >
< line x1 = "0" y1 = "36" x2 = "36" y2 = "0" stroke = "rgba(255,255,255,0.1)" strokeWidth = "0.5" strokeDasharray = "2 2" / >
< polyline
< polyline
points = { thumb . polyline }
points = { polyline }
fill = "none"
fill = "none"
stroke = { isActive ? "#bef264" : "rgba(255,255,255,0.5)" }
stroke = { isActive ? "#bef264" : "rgba(255,255,255,0.5)" }
strokeWidth = "1.5"
strokeWidth = "1.5"
strokeLinecap = "round"
strokeLinecap = "round"
strokeLinejoin = "round"
strokeLinejoin = "round"
/ >
/ >
) }
< / svg >
< / svg >
< span className = "text-[7px] text-neutral-400 truncate w-full text-center leading-none" >
< span className = "text-[7px] text-neutral-400 truncate w-full text-center leading-none" >
{ name . replace ( /^ease/ , "" ) }
{ name . replace ( /^ease/ , "" ) }
< / span >
< / span >
< / butto n>
< / button >
) ;
) ;
} ) }
} ) }
< / div >
< / div >
< / div >
< / div >
< / div >
) }
< / div >
< / div >
{ /* All easing functions (scrollable grid) */ }
{ /* Apply button */ }
< div className = "border-t border-neutral-700 pt-2" >
< div className = "px-2 pb-1" >
< div className = "text-[9px] text-neutral-500 uppercase tracking-wider mb-1 px-1" >
< button
All Easing Functions
className = "nodrag nopan px-3 py-1.5 bg-lime-300/15 hover:bg-lime-300/25 border border-lime-300/30 rounded text-xs text-lime-300 font-medium transition-colors disabled:opacity-40 disabled:pointer-events-none"
< / div >
onClick = { handleRun }
< div className = "grid grid-cols-5 gap-1 max-h-[200px] overflow-y-auto nowheel" >
disabled = { isRunning || nodeData . status === "loading" || isInherited }
{ presetThumbnails . map ( ( { name , polyline } ) = > {
>
const isActive = nodeData . easingPreset === name ;
Apply
return (
< / button >
< button
key = { name }
className = { ` nodrag nopan flex flex-col items-center gap-0.5 p-1 rounded transition-colors ${
isActive
? "bg-lime-300/20 border border-lime-300/40"
: "hover:bg-neutral-700 border border-transparent"
} ` }
onClick = { ( ) = > handleSelectEasing ( name ) }
title = { name }
>
< svg width = "40" height = "40" viewBox = "-2 -2 40 40" className = "flex-shrink-0" >
< rect x = "-2" y = "-2" width = "40" height = "40" fill = "transparent" / >
< line x1 = "0" y1 = "36" x2 = "36" y2 = "0" stroke = "rgba(255,255,255,0.1)" strokeWidth = "0.5" strokeDasharray = "2 2" / >
< polyline
points = { polyline }
fill = "none"
stroke = { isActive ? "#bef264" : "rgba(255,255,255,0.5)" }
strokeWidth = "1.5"
strokeLinecap = "round"
strokeLinejoin = "round"
/ >
< / svg >
< span className = "text-[7px] text-neutral-400 truncate w-full text-center leading-none" >
{ name . replace ( /^ease/ , "" ) }
< / span >
< / button >
) ;
} ) }
< / div >
< / div >
< / div >
) }
< / div >
< / div >
< / div >
< / div >
{ /* Apply button */ }
{ /* Inheritance overlay */ }
< div className = "px-2 pb-1" >
{ isInherited && (
< button
< div className = "absolute inset-0 flex flex-col items-center justify-center bg-neutral-900/80 backdrop-blur-sm rounded z-10" >
className = "nodrag nopan px-3 py-1.5 bg-lime-300/15 hover:bg-lime-300/25 border border-lime-300/30 rounded text-xs text-lime-300 font-medium transition-colors disabled:opacity-40 disabled:pointer-events-none"
< p className = "text-sm text-neutral-200 font-medium" > Settings inherited from parent node < / p >
onClick = { handleRun }
< p className = "text-[11px] text-neutral-400 mt-1" > Break connection to edit manually < / p >
disabled = { isRunning || nodeData . status === "loading" }
< button
>
className = "mt-3 px-3 py-1.5 bg-white/10 hover:bg-white/20 border border-white/20 rounded text-xs text-neutral-200 transition-colors"
Apply
onClick = { handleBreakInheritance }
< / button >
>
< / div >
Control manually
< / button >
< / div >
) }
< / div >
< / div >
) }
) }