From d6f3926566090180a6cd0292a2a091b4a020c0c3 Mon Sep 17 00:00:00 2001 From: Duecki Date: Wed, 13 May 2026 18:36:46 +0200 Subject: [PATCH 1/5] Init --- src-tauri/src/adjustment_utils.rs | 4 + src-tauri/src/mask_generation.rs | 94 +++++++-- src/components/panel/Editor.tsx | 22 ++- src/components/panel/right/Masks.tsx | 6 +- src/components/panel/right/MasksPanel.tsx | 222 ++++++++++++++++++---- src/hooks/useImageProcessing.ts | 6 + src/utils/adjustments.ts | 45 ++++- 7 files changed, 335 insertions(+), 64 deletions(-) diff --git a/src-tauri/src/adjustment_utils.rs b/src-tauri/src/adjustment_utils.rs index 63d9cc3a8..387d77c81 100644 --- a/src-tauri/src/adjustment_utils.rs +++ b/src-tauri/src/adjustment_utils.rs @@ -40,6 +40,10 @@ pub fn hydrate_sub_masks( } } } + + if let Some(nested_sub_masks) = params.get_mut("subMasks").and_then(|v| v.as_array_mut()) { + hydrate_sub_masks(nested_sub_masks, cache); + } } } } diff --git a/src-tauri/src/mask_generation.rs b/src-tauri/src/mask_generation.rs index 050d15e5e..ffafa5424 100644 --- a/src-tauri/src/mask_generation.rs +++ b/src-tauri/src/mask_generation.rs @@ -59,9 +59,7 @@ pub struct MaskDefinition { impl MaskDefinition { pub fn requires_warped_image(&self) -> bool { - self.sub_masks - .iter() - .any(|sm| sm.mask_type == "color" || sm.mask_type == "luminance") + self.sub_masks.iter().any(SubMask::requires_warped_image) } } @@ -98,6 +96,27 @@ struct GrowFeatherParameters { feather: f32, } +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +#[serde(rename_all = "camelCase")] +struct CustomComponentParameters { + #[serde(default)] + sub_masks: Vec, +} + +impl SubMask { + fn requires_warped_image(&self) -> bool { + match self.mask_type.as_str() { + "color" | "luminance" => true, + "custom-component" => { + let params: CustomComponentParameters = + serde_json::from_value(self.parameters.clone()).unwrap_or_default(); + params.sub_masks.iter().any(SubMask::requires_warped_image) + } + _ => false, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default)] #[serde(rename_all = "camelCase")] struct RadialMaskParameters { @@ -1198,6 +1217,31 @@ fn generate_all_bitmap(width: u32, height: u32) -> GrayImage { GrayImage::from_pixel(width, height, Luma([255])) } +fn generate_custom_component_bitmap( + params_value: &Value, + width: u32, + height: u32, + scale: f32, + crop_offset: (f32, f32), + warped_image: Option<&DynamicImage>, +) -> Option { + let params: CustomComponentParameters = + serde_json::from_value(params_value.clone()).unwrap_or_default(); + + if params.sub_masks.is_empty() { + return None; + } + + Some(compose_sub_masks( + ¶ms.sub_masks, + width, + height, + scale, + crop_offset, + warped_image, + )) +} + fn generate_sub_mask_bitmap( sub_mask: &SubMask, width: u32, @@ -1269,25 +1313,29 @@ fn generate_sub_mask_bitmap( generate_ai_subject_bitmap(&sub_mask.parameters, width, height, scale, crop_offset) } "all" => Some(generate_all_bitmap(width, height)), + "custom-component" => generate_custom_component_bitmap( + &sub_mask.parameters, + width, + height, + scale, + crop_offset, + warped_image, + ), _ => None, } } -pub fn generate_mask_bitmap( - mask_def: &MaskDefinition, +fn compose_sub_masks( + sub_masks: &[SubMask], width: u32, height: u32, scale: f32, crop_offset: (f32, f32), warped_image: Option<&DynamicImage>, -) -> Option { - if !mask_def.visible || mask_def.sub_masks.is_empty() { - return None; - } - +) -> GrayImage { let mut final_mask = GrayImage::new(width, height); - for sub_mask in &mask_def.sub_masks { + for sub_mask in sub_masks { if let Some(mut sub_bitmap) = generate_sub_mask_bitmap(sub_mask, width, height, scale, crop_offset, warped_image) { @@ -1327,6 +1375,30 @@ pub fn generate_mask_bitmap( } } + final_mask +} + +pub fn generate_mask_bitmap( + mask_def: &MaskDefinition, + width: u32, + height: u32, + scale: f32, + crop_offset: (f32, f32), + warped_image: Option<&DynamicImage>, +) -> Option { + if !mask_def.visible || mask_def.sub_masks.is_empty() { + return None; + } + + let mut final_mask = compose_sub_masks( + &mask_def.sub_masks, + width, + height, + scale, + crop_offset, + warped_image, + ); + if mask_def.invert { for pixel in final_mask.pixels_mut() { pixel[0] = 255 - pixel[0]; diff --git a/src/components/panel/Editor.tsx b/src/components/panel/Editor.tsx index 6e940e218..d4ac93c06 100644 --- a/src/components/panel/Editor.tsx +++ b/src/components/panel/Editor.tsx @@ -58,6 +58,20 @@ const checkCropValid = (pixelCrop: Partial, imageW: number, imageH: number return true; }; +const cleanSubMasksForOverlayHash = (subMasks: Array = []) => + subMasks.map((sm: any) => { + const { parameters, ...rest } = sm; + const cleanParams = { ...(parameters || {}) }; + delete cleanParams.mask_data_base64; + delete cleanParams.maskDataBase64; + + if (Array.isArray(cleanParams.subMasks)) { + cleanParams.subMasks = cleanSubMasksForOverlayHash(cleanParams.subMasks); + } + + return { ...rest, parameters: cleanParams }; + }); + interface WgpuRenderState { useWgpuRenderer: boolean | undefined; isReady: boolean; @@ -1266,13 +1280,7 @@ export default function Editor({ onBackToLibrary, onContextMenu, transformWrappe geometry[k] = (adjustments as any)[k]; }); - const subMasks = activeMaskDef.subMasks?.map((sm: any) => { - const { parameters, ...rest } = sm; - const cleanParams = { ...parameters }; - delete cleanParams.mask_data_base64; - delete cleanParams.maskDataBase64; - return { ...rest, parameters: cleanParams }; - }); + const subMasks = cleanSubMasksForOverlayHash(activeMaskDef.subMasks); return JSON.stringify({ id: activeMaskDef.id, diff --git a/src/components/panel/right/Masks.tsx b/src/components/panel/right/Masks.tsx index ead74728d..7008e68c3 100644 --- a/src/components/panel/right/Masks.tsx +++ b/src/components/panel/right/Masks.tsx @@ -3,6 +3,7 @@ import { BringToFront, Circle, Cloud, + Component, Droplet, Droplets, Eraser, @@ -23,6 +24,7 @@ export enum Mask { Brush = 'brush', Flow = 'flow', Color = 'color', + CustomComponent = 'custom-component', Linear = 'linear', Luminance = 'luminance', QuickEraser = 'quick-eraser', @@ -48,7 +50,7 @@ export interface MaskType { icon: any; id?: string; name: string; - type: Mask; + type: Mask | null; } export interface SubMask { @@ -68,6 +70,7 @@ export function formatMaskTypeName(type: string) { if (type === Mask.AiForeground) return 'Foreground'; if (type === Mask.AiSky) return 'Sky'; if (type === Mask.All) return 'Whole Image'; + if (type === Mask.CustomComponent) return 'Custom Component'; if (type === Mask.QuickEraser) return 'Quick Eraser'; return type.charAt(0).toUpperCase() + type.slice(1); } @@ -85,6 +88,7 @@ export const MASK_ICON_MAP: Record = { [Mask.Brush]: Brush, [Mask.Flow]: Droplets, [Mask.Color]: Droplet, + [Mask.CustomComponent]: Component, [Mask.Linear]: TriangleRight, [Mask.Luminance]: Sparkles, [Mask.QuickEraser]: Eraser, diff --git a/src/components/panel/right/MasksPanel.tsx b/src/components/panel/right/MasksPanel.tsx index 6aade87f5..c73543d2e 100644 --- a/src/components/panel/right/MasksPanel.tsx +++ b/src/components/panel/right/MasksPanel.tsx @@ -26,6 +26,7 @@ import { ChartArea, Circle, ClipboardPaste, + Component, Copy, Eye, EyeOff, @@ -69,6 +70,7 @@ import { Adjustments, INITIAL_MASK_ADJUSTMENTS, INITIAL_MASK_CONTAINER, + CustomMaskComponent, MaskContainer, ADJUSTMENT_SECTIONS, } from '../../../utils/adjustments'; @@ -107,6 +109,7 @@ const SUB_MASK_CONFIG: Record = { { key: 'feather', label: 'Feather', min: 0, max: 100, step: 1, defaultValue: 35 }, ], }, + [Mask.CustomComponent]: { parameters: [] }, [Mask.Luminance]: { parameters: [ { key: 'tolerance', label: 'Tolerance', min: 1, max: 100, step: 1, defaultValue: 20 }, @@ -139,6 +142,45 @@ const SUB_MASK_CONFIG: Record = { [Mask.QuickEraser]: { parameters: [] }, }; +const cloneSubMaskData = (subMask: SubMask, options: { invert?: boolean; rename?: boolean } = {}): SubMask => { + const clonedSubMask = JSON.parse(JSON.stringify(subMask)); + + clonedSubMask.id = uuidv4(); + clonedSubMask.invert = options.invert ? !clonedSubMask.invert : clonedSubMask.invert; + clonedSubMask.name = options.rename === false ? clonedSubMask.name : `${getSubMaskName(subMask)} Copy`; + + if (Array.isArray(clonedSubMask.parameters?.subMasks)) { + clonedSubMask.parameters = { + ...clonedSubMask.parameters, + subMasks: clonedSubMask.parameters.subMasks.map((nestedSubMask: SubMask) => + cloneSubMaskData(nestedSubMask, { rename: false }), + ), + }; + } + + return clonedSubMask; +}; + +const cloneSubMaskTree = (subMasks: Array): Array => + (subMasks || []).map((subMask) => cloneSubMaskData(subMask, { rename: false })); + +const createCustomComponentSubMask = ( + component: CustomMaskComponent, + mode: SubMaskMode = SubMaskMode.Additive, +): SubMask => ({ + id: uuidv4(), + visible: true, + invert: false, + opacity: 100, + mode, + name: component.name, + type: Mask.CustomComponent, + parameters: { + customComponentId: component.id, + subMasks: cloneSubMaskTree(component.subMasks), + }, +}); + const BrushTools = ({ settings, onSettingsChange, @@ -649,9 +691,24 @@ export default function MasksPanel() { const activeContainer = adjustments.masks?.find((m) => m.id === activeMaskContainerId); const activeSubMaskData = activeContainer?.subMasks?.find((sm) => sm.id === activeMaskId); + const customMaskComponents = adjustments.customMaskComponents || []; const isAiMask = activeSubMaskData && [Mask.AiSubject, Mask.AiForeground, Mask.AiSky, Mask.AiDepth].includes(activeSubMaskData.type); + const getUniqueCustomComponentName = (baseName: string) => { + const normalizedBase = baseName.trim() || 'Custom Component'; + const usedNames = new Set(customMaskComponents.map((component) => component.name.toLowerCase())); + let candidate = normalizedBase; + let suffix = 2; + + while (usedNames.has(candidate.toLowerCase())) { + candidate = `${normalizedBase} ${suffix}`; + suffix += 1; + } + + return candidate; + }; + useEffect(() => { let timer: ReturnType | null = null; if (isGeneratingAiMask && isAiMask) { @@ -734,12 +791,12 @@ export default function MasksPanel() { const handleResetAllMasks = () => { handleDeselect(); - setAdjustments((prev: any) => ({ ...prev, masks: [] })); + setAdjustments((prev: any) => ({ ...prev, masks: [], customMaskComponents: [] })); }; - const createMaskLogic = (type: Mask, mode: SubMaskMode = SubMaskMode.Additive) => { - if (!selectedImage) return createSubMask(type, {} as any, mode); - const subMask = createSubMask(type, selectedImage, mode); + const createMaskLogic = (type: Mask, mode: SubMaskMode = SubMaskMode.Additive): SubMask => { + if (!selectedImage) return createSubMask(type, {} as any, mode) as SubMask; + const subMask = createSubMask(type, selectedImage, mode) as SubMask; const steps = adjustments?.orientationSteps || 0; const isRotated = steps === 1 || steps === 3; @@ -830,6 +887,48 @@ export default function MasksPanel() { else if (type === Mask.AiDepth) handleGenerateAiDepthMask(subMask.id, subMask.parameters); }; + const handleAddCustomComponent = ( + component: CustomMaskComponent, + targetContainerId?: string | null, + mode: SubMaskMode = SubMaskMode.Additive, + insertIndex: number = -1, + ) => { + const subMask = createCustomComponentSubMask(component, mode); + + if (targetContainerId) { + setAdjustments((prev: Adjustments) => ({ + ...prev, + masks: prev.masks?.map((container: MaskContainer) => { + if (container.id !== targetContainerId) return container; + + const newSubMasks = [...container.subMasks]; + if (insertIndex >= 0) { + newSubMasks.splice(insertIndex, 0, subMask); + } else { + newSubMasks.push(subMask); + } + + return { ...container, subMasks: newSubMasks }; + }), + })); + onSelectContainer(targetContainerId); + onSelectMask(subMask.id); + setExpandedContainers((prev) => new Set(prev).add(targetContainerId)); + return; + } + + const newContainer = { + ...INITIAL_MASK_CONTAINER, + id: uuidv4(), + name: `Mask ${(adjustments.masks?.length || 0) + 1}`, + subMasks: [subMask], + }; + setAdjustments((prev: Adjustments) => ({ ...prev, masks: [...(prev.masks || []), newContainer] })); + onSelectContainer(newContainer.id); + onSelectMask(subMask.id); + setExpandedContainers((prev) => new Set(prev).add(newContainer.id)); + }; + const handleGridClick = (type: Mask, forceNewMaskContainer: boolean = false) => { if (!forceNewMaskContainer && activeMaskContainerId) handleAddSubMask(activeMaskContainerId, type); else handleAddMaskContainer(type); @@ -843,14 +942,29 @@ export default function MasksPanel() { handleGridClick(type, true); }; + const buildCustomComponentSubmenu = ( + targetContainerId?: string | null, + mode: SubMaskMode = SubMaskMode.Additive, + ) => ({ + label: 'Custom', + icon: Component, + submenu: customMaskComponents.length + ? customMaskComponents.map((component) => ({ + label: component.name, + icon: Component, + onClick: () => handleAddCustomComponent(component, targetContainerId, mode), + })) + : [{ label: 'No custom components', disabled: true }], + }); + const handleAddOthersMask = (event: React.MouseEvent) => { event.stopPropagation(); const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); const options = OTHERS_MASK_TYPES.map((maskType) => ({ label: maskType.name, icon: maskType.icon, - onClick: () => handleGridClick(maskType.type), - onRightClick: () => handleGridClick(maskType.type, true), + onClick: () => handleGridClick(maskType.type!), + onRightClick: () => handleGridClick(maskType.type!, true), })); showContextMenu(rect.left, rect.bottom + 5, options); }; @@ -867,9 +981,9 @@ export default function MasksPanel() { disabled: maskType.disabled, onClick: () => { if (targetContainerId) { - handleAddSubMask(targetContainerId, maskType.type, mode); + handleAddSubMask(targetContainerId, maskType.type!, mode); } else { - handleAddMaskContainer(maskType.type); + handleAddMaskContainer(maskType.type!); } }, })); @@ -877,10 +991,8 @@ export default function MasksPanel() { const container = targetContainerId ? adjustments.masks?.find((m) => m.id === targetContainerId) : null; const hasComponents = container && container.subMasks.length > 0; - const buildModeSubmenu = (label: string, icon: any, mode: SubMaskMode) => ({ - label, - icon, - submenu: MASK_PANEL_CREATION_TYPES.map((maskType) => { + const buildModeSubmenu = (label: string, icon: any, mode: SubMaskMode) => { + const submenu: any[] = MASK_PANEL_CREATION_TYPES.map((maskType) => { if (maskType.id === 'others') { return { label: maskType.name, @@ -892,10 +1004,18 @@ export default function MasksPanel() { label: maskType.name, icon: maskType.icon, disabled: maskType.disabled, - onClick: () => handleAddSubMask(targetContainerId!, maskType.type, mode), + onClick: () => handleAddSubMask(targetContainerId!, maskType.type!, mode), }; - }), - }); + }); + + submenu.push(buildCustomComponentSubmenu(targetContainerId, mode)); + + return { + label, + icon, + submenu, + }; + }; const options: any[] = buildMenu( MASK_PANEL_CREATION_TYPES.filter((m) => m.id !== 'others'), @@ -909,6 +1029,7 @@ export default function MasksPanel() { submenu: buildMenu(OTHERS_MASK_TYPES, SubMaskMode.Additive), }); } + options.push(buildCustomComponentSubmenu(targetContainerId, SubMaskMode.Additive)); if (targetContainerId && hasComponents) { options.push( @@ -959,24 +1080,11 @@ export default function MasksPanel() { clonedContainer.id = uuidv4(); clonedContainer.invert = options.invert ? !clonedContainer.invert : clonedContainer.invert; clonedContainer.name = options.rename === false ? clonedContainer.name : `${container.name} Copy`; - clonedContainer.subMasks = clonedContainer.subMasks.map((subMask: SubMask) => ({ - ...subMask, - id: uuidv4(), - })); + clonedContainer.subMasks = cloneSubMaskTree(clonedContainer.subMasks); return clonedContainer; }; - const cloneSubMaskData = (subMask: SubMask, options: { invert?: boolean; rename?: boolean } = {}): SubMask => { - const clonedSubMask = JSON.parse(JSON.stringify(subMask)); - - clonedSubMask.id = uuidv4(); - clonedSubMask.invert = options.invert ? !clonedSubMask.invert : clonedSubMask.invert; - clonedSubMask.name = options.rename === false ? clonedSubMask.name : `${getSubMaskName(subMask)} Copy`; - - return clonedSubMask; - }; - const copyMaskToClipboard = (container: MaskContainer) => { setCopiedMask(JSON.parse(JSON.stringify(container))); }; @@ -1081,6 +1189,39 @@ export default function MasksPanel() { insertSubMaskIntoContainer(containerId, pastedSubMask, insertIndex); }; + const handleConvertContainerToComponent = (container: MaskContainer) => { + if (!container.subMasks.length) { + return; + } + + const defaultName = getUniqueCustomComponentName(`${container.name} Component`); + const promptedName = window.prompt('Custom component name', defaultName); + const name = promptedName?.trim(); + + if (!name) { + return; + } + + const customComponent: CustomMaskComponent = { + id: uuidv4(), + name, + subMasks: cloneSubMaskTree(container.subMasks), + }; + const mergedSubMask = createCustomComponentSubMask(customComponent); + + setAdjustments((prev: Adjustments) => ({ + ...prev, + customMaskComponents: [...(prev.customMaskComponents || []), customComponent], + masks: prev.masks.map((maskContainer) => + maskContainer.id === container.id ? { ...maskContainer, subMasks: [mergedSubMask] } : maskContainer, + ), + })); + + onSelectContainer(container.id); + onSelectMask(mergedSubMask.id); + setExpandedContainers((prev) => new Set(prev).add(container.id)); + }; + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); const handleDragStart = (event: DragStartEvent) => { @@ -1100,8 +1241,8 @@ export default function MasksPanel() { } else if (overData?.type === 'SubMask') { const container = adjustments.masks.find((m) => m.id === overData.parentId); if (container) { - const targetIndex = container.subMasks.findIndex((sm) => sm.id === over.id); - handleAddSubMask(overData.parentId!, dragData.maskType!, targetIndex); + const targetIndex = container.subMasks.findIndex((sm) => sm.id === over!.id); + handleAddSubMask(overData.parentId!, dragData.maskType!, SubMaskMode.Additive, targetIndex); } } else { handleAddMaskContainer(dragData.maskType!); @@ -1224,11 +1365,12 @@ export default function MasksPanel() { const handlePanelContextMenu = (e: React.MouseEvent) => { e.preventDefault(); const allTypes = [...MASK_PANEL_CREATION_TYPES.filter((m) => m.id !== 'others'), ...OTHERS_MASK_TYPES]; - const newMaskSubMenu = allTypes.map((m) => ({ + const newMaskSubMenu: any[] = allTypes.map((m) => ({ label: m.name, icon: m.icon, - onClick: () => handleAddMaskContainer(m.type), + onClick: () => handleAddMaskContainer(m.type!), })); + newMaskSubMenu.push(buildCustomComponentSubmenu(null, SubMaskMode.Additive)); showContextMenu(e.clientX, e.clientY, [ { label: 'Paste Mask', icon: ClipboardPaste, disabled: !copiedMask, onClick: () => handlePasteMask() }, { label: 'Add New Mask', icon: Plus, submenu: newMaskSubMenu }, @@ -1316,7 +1458,7 @@ export default function MasksPanel() { key={maskType.type || maskType.id} maskType={maskType} onClick={(e: any) => - maskType.id === 'others' ? handleAddOthersMask(e) : handleGridClick(maskType.type) + maskType.id === 'others' ? handleAddOthersMask(e) : handleGridClick(maskType.type!) } onRightClick={(e: React.MouseEvent) => handleGridRightClick(e, maskType.type)} isDraggable={maskType.id !== 'others'} @@ -1370,6 +1512,7 @@ export default function MasksPanel() { handleDelete={handleDeleteContainer} handleDuplicate={handleDuplicateContainer} handleDuplicateAndInvert={handleDuplicateAndInvertContainer} + handleConvertToComponent={handleConvertContainerToComponent} handlePasteMask={handlePasteMask} copyMaskToClipboard={copyMaskToClipboard} copiedMask={copiedMask} @@ -1591,6 +1734,7 @@ function ContainerRow({ handleDelete, handleDuplicate, handleDuplicateAndInvert, + handleConvertToComponent, handlePasteMask, copyMaskToClipboard, copiedMask, @@ -1670,6 +1814,12 @@ function ContainerRow({ }, { label: 'Duplicate Mask', icon: PlusSquare, onClick: () => handleDuplicate(container) }, { label: 'Duplicate and Invert Mask', icon: RotateCcw, onClick: () => handleDuplicateAndInvert(container) }, + { + label: 'Convert to Component', + icon: Component, + disabled: container.subMasks.length === 0, + onClick: () => handleConvertToComponent(container), + }, { label: 'Copy Mask', icon: Copy, onClick: () => copyMaskToClipboard(container) }, { label: 'Paste Mask', @@ -1908,7 +2058,7 @@ function SubMaskRow({ setNodeRef(node); setDroppableRef(node); }; - const MaskIcon = MASK_ICON_MAP[subMask.type] || Circle; + const MaskIcon = MASK_ICON_MAP[subMask.type as Mask] || Circle; const { showContextMenu } = useContextMenu(); const [isHovered, setIsHovered] = useState(false); const hoverTimeoutRef = useRef | null>(null); @@ -2208,7 +2358,7 @@ function SettingsPanel({ updateSubMask(activeSubMask.id, { parameters: newParams }); }; - const subMaskConfig = activeSubMask ? SUB_MASK_CONFIG[activeSubMask.type] || {} : {}; + const subMaskConfig = activeSubMask ? SUB_MASK_CONFIG[activeSubMask.type as Mask] || {} : {}; const isAiMask = activeSubMask && ['ai-subject', 'ai-foreground', 'ai-sky', 'ai-depth'].includes(activeSubMask.type); const isComponentMode = !!activeSubMask; diff --git a/src/hooks/useImageProcessing.ts b/src/hooks/useImageProcessing.ts index fb41d7d4a..b8623d9ee 100644 --- a/src/hooks/useImageProcessing.ts +++ b/src/hooks/useImageProcessing.ts @@ -143,6 +143,10 @@ export function useImageProcessing( if (foundMaskData && !patchesSentToBackend.has(sm.id)) { patchesSentToBackend.add(sm.id); } + + if (Array.isArray(sm.parameters.subMasks)) { + processSubMasks(sm.parameters.subMasks); + } } }); }; @@ -166,6 +170,8 @@ export function useImageProcessing( }); } + delete (payload as any).customMaskComponents; + const jobId = ++previewJobIdRef.current; const roi = calculateROI(); diff --git a/src/utils/adjustments.ts b/src/utils/adjustments.ts index b6705d118..b92976381 100644 --- a/src/utils/adjustments.ts +++ b/src/utils/adjustments.ts @@ -1,6 +1,6 @@ import { Crop } from 'react-image-crop'; import { v4 as uuidv4 } from 'uuid'; -import { SubMask, SubMaskMode } from '../components/panel/right/Masks'; +import { Mask, SubMask, SubMaskMode } from '../components/panel/right/Masks'; export enum ActiveChannel { Blue = 'blue', @@ -159,6 +159,7 @@ export interface Adjustments { colorGrading: ColorGradingProps; colorNoiseReduction: number; contrast: number; + customMaskComponents: Array; curves: Curves; pointCurves?: Curves; parametricCurve?: ParametricCurve; @@ -241,6 +242,12 @@ export interface AiPatch { visible: boolean; } +export interface CustomMaskComponent { + id: string; + name: string; + subMasks: Array; +} + export interface Color { color: string; name: string; @@ -483,6 +490,7 @@ export const INITIAL_ADJUSTMENTS: Adjustments = { colorGrading: { ...INITIAL_COLOR_GRADING }, colorNoiseReduction: 0, contrast: 0, + customMaskComponents: [], crop: null, curves: getDefaultCurves(), pointCurves: getDefaultCurves(), @@ -590,16 +598,34 @@ export const normalizeLoadedAdjustments = (loadedAdjustments: Adjustments): any return INITIAL_ADJUSTMENTS; } - const normalizeSubMasks = (subMasks: any[]) => { - return (subMasks || []).map((subMask: Partial) => ({ - visible: true, - mode: SubMaskMode.Additive, - invert: false, - opacity: 100, - ...subMask, - })); + const normalizeSubMasks = (subMasks: any[]): Array => { + return (subMasks || []).map((subMask: Partial) => { + const normalized = { + visible: true, + mode: SubMaskMode.Additive, + invert: false, + opacity: 100, + ...subMask, + } as SubMask; + + if (normalized.type === Mask.CustomComponent && Array.isArray(normalized.parameters?.subMasks)) { + normalized.parameters = { + ...normalized.parameters, + subMasks: normalizeSubMasks(normalized.parameters.subMasks), + }; + } + + return normalized; + }); }; + const normalizeCustomMaskComponents = (components: any[]): Array => + (components || []).map((component: Partial) => ({ + id: component.id || uuidv4(), + name: component.name?.trim() || 'Custom Component', + subMasks: normalizeSubMasks(component.subMasks || []), + })); + const normalizedMasks = (loadedAdjustments.masks || []).map((maskContainer: MaskContainer) => { const containerAdjustments = maskContainer.adjustments || {}; const normalizedSubMasks = normalizeSubMasks(maskContainer.subMasks); @@ -674,6 +700,7 @@ export const normalizeLoadedAdjustments = (loadedAdjustments: Adjustments): any curveMode: loadedAdjustments.curveMode || INITIAL_ADJUSTMENTS.curveMode, masks: normalizedMasks, aiPatches: normalizedAiPatches, + customMaskComponents: normalizeCustomMaskComponents(loadedAdjustments.customMaskComponents), sectionVisibility: { ...INITIAL_ADJUSTMENTS.sectionVisibility, ...(loadedAdjustments.sectionVisibility || {}), From 2415ec399b7db14b9d1a34a56f3fcce31c5dc396 Mon Sep 17 00:00:00 2001 From: Duecki Date: Wed, 13 May 2026 19:05:22 +0200 Subject: [PATCH 2/5] Remove pop up, rename inline and hide custom submenu --- src/components/panel/right/MasksPanel.tsx | 108 ++++++++++++++-------- 1 file changed, 72 insertions(+), 36 deletions(-) diff --git a/src/components/panel/right/MasksPanel.tsx b/src/components/panel/right/MasksPanel.tsx index c73543d2e..0ec567985 100644 --- a/src/components/panel/right/MasksPanel.tsx +++ b/src/components/panel/right/MasksPanel.tsx @@ -695,15 +695,15 @@ export default function MasksPanel() { const isAiMask = activeSubMaskData && [Mask.AiSubject, Mask.AiForeground, Mask.AiSky, Mask.AiDepth].includes(activeSubMaskData.type); - const getUniqueCustomComponentName = (baseName: string) => { - const normalizedBase = baseName.trim() || 'Custom Component'; + const getNextCustomComponentName = () => { + const normalizedBase = 'Custom Component'; const usedNames = new Set(customMaskComponents.map((component) => component.name.toLowerCase())); - let candidate = normalizedBase; - let suffix = 2; + let suffix = 1; + let candidate = `${normalizedBase} ${suffix}`; while (usedNames.has(candidate.toLowerCase())) { - candidate = `${normalizedBase} ${suffix}`; suffix += 1; + candidate = `${normalizedBase} ${suffix}`; } return candidate; @@ -942,20 +942,19 @@ export default function MasksPanel() { handleGridClick(type, true); }; - const buildCustomComponentSubmenu = ( - targetContainerId?: string | null, - mode: SubMaskMode = SubMaskMode.Additive, - ) => ({ - label: 'Custom', - icon: Component, - submenu: customMaskComponents.length - ? customMaskComponents.map((component) => ({ - label: component.name, - icon: Component, - onClick: () => handleAddCustomComponent(component, targetContainerId, mode), - })) - : [{ label: 'No custom components', disabled: true }], - }); + const buildCustomComponentSubmenu = (targetContainerId?: string | null, mode: SubMaskMode = SubMaskMode.Additive) => { + if (!customMaskComponents.length) return null; + + return { + label: 'Custom', + icon: Component, + submenu: customMaskComponents.map((component) => ({ + label: component.name, + icon: Component, + onClick: () => handleAddCustomComponent(component, targetContainerId, mode), + })), + }; + }; const handleAddOthersMask = (event: React.MouseEvent) => { event.stopPropagation(); @@ -1008,7 +1007,10 @@ export default function MasksPanel() { }; }); - submenu.push(buildCustomComponentSubmenu(targetContainerId, mode)); + const customComponentSubmenu = buildCustomComponentSubmenu(targetContainerId, mode); + if (customComponentSubmenu) { + submenu.push(customComponentSubmenu); + } return { label, @@ -1029,7 +1031,10 @@ export default function MasksPanel() { submenu: buildMenu(OTHERS_MASK_TYPES, SubMaskMode.Additive), }); } - options.push(buildCustomComponentSubmenu(targetContainerId, SubMaskMode.Additive)); + const customComponentSubmenu = buildCustomComponentSubmenu(targetContainerId, SubMaskMode.Additive); + if (customComponentSubmenu) { + options.push(customComponentSubmenu); + } if (targetContainerId && hasComponents) { options.push( @@ -1048,13 +1053,46 @@ export default function MasksPanel() { masks: prev.masks.map((m) => (m.id === id ? { ...m, ...data } : m)), })); const updateSubMask = (id: string, data: any) => - setAdjustments((prev: Adjustments) => ({ - ...prev, - masks: prev.masks.map((m) => ({ + setAdjustments((prev: Adjustments) => { + const renamedCustomComponentName = typeof data.name === 'string' ? data.name.trim() : ''; + let renamedCustomComponentId: string | null = null; + + const masks = prev.masks.map((m) => ({ ...m, - subMasks: m.subMasks.map((sm) => (sm.id === id ? { ...sm, ...data } : sm)), - })), - })); + subMasks: m.subMasks.map((sm) => { + if (sm.id !== id) return sm; + + if ( + renamedCustomComponentName && + sm.type === Mask.CustomComponent && + typeof sm.parameters?.customComponentId === 'string' + ) { + renamedCustomComponentId = sm.parameters.customComponentId; + } + + return { ...sm, ...data }; + }), + })); + + if (!renamedCustomComponentId || !renamedCustomComponentName) { + return { ...prev, masks }; + } + + return { + ...prev, + customMaskComponents: (prev.customMaskComponents || []).map((component) => + component.id === renamedCustomComponentId ? { ...component, name: renamedCustomComponentName } : component, + ), + masks: masks.map((m) => ({ + ...m, + subMasks: m.subMasks.map((sm) => + sm.type === Mask.CustomComponent && sm.parameters?.customComponentId === renamedCustomComponentId + ? { ...sm, name: renamedCustomComponentName } + : sm, + ), + })), + }; + }); const handleDeleteContainer = (id: string) => { if (activeMaskContainerId === id) handleDeselect(); @@ -1194,14 +1232,7 @@ export default function MasksPanel() { return; } - const defaultName = getUniqueCustomComponentName(`${container.name} Component`); - const promptedName = window.prompt('Custom component name', defaultName); - const name = promptedName?.trim(); - - if (!name) { - return; - } - + const name = getNextCustomComponentName(); const customComponent: CustomMaskComponent = { id: uuidv4(), name, @@ -1219,6 +1250,8 @@ export default function MasksPanel() { onSelectContainer(container.id); onSelectMask(mergedSubMask.id); + setRenamingId(mergedSubMask.id); + setTempName(name); setExpandedContainers((prev) => new Set(prev).add(container.id)); }; @@ -1370,7 +1403,10 @@ export default function MasksPanel() { icon: m.icon, onClick: () => handleAddMaskContainer(m.type!), })); - newMaskSubMenu.push(buildCustomComponentSubmenu(null, SubMaskMode.Additive)); + const customComponentSubmenu = buildCustomComponentSubmenu(null, SubMaskMode.Additive); + if (customComponentSubmenu) { + newMaskSubMenu.push(customComponentSubmenu); + } showContextMenu(e.clientX, e.clientY, [ { label: 'Paste Mask', icon: ClipboardPaste, disabled: !copiedMask, onClick: () => handlePasteMask() }, { label: 'Add New Mask', icon: Plus, submenu: newMaskSubMenu }, From 59e72015ab5b8d05196f1c65b81dbf1c31d0c0b6 Mon Sep 17 00:00:00 2001 From: Duecki Date: Wed, 13 May 2026 19:14:34 +0200 Subject: [PATCH 3/5] Fix deep cloning --- src-tauri/src/mask_generation.rs | 4 ++-- src/components/panel/Editor.tsx | 11 +++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/mask_generation.rs b/src-tauri/src/mask_generation.rs index ffafa5424..7e0bddb3a 100644 --- a/src-tauri/src/mask_generation.rs +++ b/src-tauri/src/mask_generation.rs @@ -109,7 +109,7 @@ impl SubMask { "color" | "luminance" => true, "custom-component" => { let params: CustomComponentParameters = - serde_json::from_value(self.parameters.clone()).unwrap_or_default(); + CustomComponentParameters::deserialize(&self.parameters).unwrap_or_default(); params.sub_masks.iter().any(SubMask::requires_warped_image) } _ => false, @@ -1226,7 +1226,7 @@ fn generate_custom_component_bitmap( warped_image: Option<&DynamicImage>, ) -> Option { let params: CustomComponentParameters = - serde_json::from_value(params_value.clone()).unwrap_or_default(); + CustomComponentParameters::deserialize(params_value).unwrap_or_default(); if params.sub_masks.is_empty() { return None; diff --git a/src/components/panel/Editor.tsx b/src/components/panel/Editor.tsx index d4ac93c06..65a81bcb7 100644 --- a/src/components/panel/Editor.tsx +++ b/src/components/panel/Editor.tsx @@ -58,18 +58,17 @@ const checkCropValid = (pixelCrop: Partial, imageW: number, imageH: number return true; }; -const cleanSubMasksForOverlayHash = (subMasks: Array = []) => - subMasks.map((sm: any) => { - const { parameters, ...rest } = sm; - const cleanParams = { ...(parameters || {}) }; +const cleanSubMasksForOverlayHash = (subMasks: Array = []): Array => + subMasks.map((subMask) => { + const cleanParams: Record = { ...(subMask.parameters || {}) }; delete cleanParams.mask_data_base64; delete cleanParams.maskDataBase64; if (Array.isArray(cleanParams.subMasks)) { - cleanParams.subMasks = cleanSubMasksForOverlayHash(cleanParams.subMasks); + cleanParams.subMasks = cleanSubMasksForOverlayHash(cleanParams.subMasks as Array); } - return { ...rest, parameters: cleanParams }; + return { ...subMask, parameters: cleanParams }; }); interface WgpuRenderState { From a5cbec421e2725491bee0a97fd3ba65538bcf5e9 Mon Sep 17 00:00:00 2001 From: Duecki Date: Wed, 13 May 2026 19:26:58 +0200 Subject: [PATCH 4/5] Add component override option --- src/components/panel/right/MasksPanel.tsx | 91 ++++++++++++++++++++++- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/src/components/panel/right/MasksPanel.tsx b/src/components/panel/right/MasksPanel.tsx index 0ec567985..bf76b5a42 100644 --- a/src/components/panel/right/MasksPanel.tsx +++ b/src/components/panel/right/MasksPanel.tsx @@ -164,6 +164,23 @@ const cloneSubMaskData = (subMask: SubMask, options: { invert?: boolean; rename? const cloneSubMaskTree = (subMasks: Array): Array => (subMasks || []).map((subMask) => cloneSubMaskData(subMask, { rename: false })); +const cloneSubMaskTreeForCustomComponent = (subMasks: Array, customComponentId: string): Array => + (subMasks || []).flatMap((subMask) => { + if (subMask.type === Mask.CustomComponent && subMask.parameters?.customComponentId === customComponentId) { + return cloneSubMaskTreeForCustomComponent(subMask.parameters?.subMasks || [], customComponentId); + } + + const clonedSubMask = cloneSubMaskData(subMask, { rename: false }); + if (Array.isArray(clonedSubMask.parameters?.subMasks)) { + clonedSubMask.parameters = { + ...clonedSubMask.parameters, + subMasks: cloneSubMaskTreeForCustomComponent(clonedSubMask.parameters.subMasks, customComponentId), + }; + } + + return [clonedSubMask]; + }); + const createCustomComponentSubMask = ( component: CustomMaskComponent, mode: SubMaskMode = SubMaskMode.Additive, @@ -1227,7 +1244,7 @@ export default function MasksPanel() { insertSubMaskIntoContainer(containerId, pastedSubMask, insertIndex); }; - const handleConvertContainerToComponent = (container: MaskContainer) => { + const handleConvertContainerToNewComponent = (container: MaskContainer) => { if (!container.subMasks.length) { return; } @@ -1255,6 +1272,50 @@ export default function MasksPanel() { setExpandedContainers((prev) => new Set(prev).add(container.id)); }; + const handleConvertContainerToExistingComponent = (container: MaskContainer, component: CustomMaskComponent) => { + if (!container.subMasks.length) { + return; + } + + const updatedComponent = { + ...component, + subMasks: cloneSubMaskTreeForCustomComponent(container.subMasks, component.id), + }; + const mergedSubMask = createCustomComponentSubMask(updatedComponent); + + setAdjustments((prev: Adjustments) => ({ + ...prev, + customMaskComponents: (prev.customMaskComponents || []).map((customComponent) => + customComponent.id === component.id ? updatedComponent : customComponent, + ), + masks: prev.masks.map((maskContainer) => { + if (maskContainer.id === container.id) { + return { ...maskContainer, subMasks: [mergedSubMask] }; + } + + return { + ...maskContainer, + subMasks: maskContainer.subMasks.map((subMask) => + subMask.type === Mask.CustomComponent && subMask.parameters?.customComponentId === component.id + ? { + ...subMask, + name: updatedComponent.name, + parameters: { + ...subMask.parameters, + subMasks: cloneSubMaskTree(updatedComponent.subMasks), + }, + } + : subMask, + ), + }; + }), + })); + + onSelectContainer(container.id); + onSelectMask(mergedSubMask.id); + setExpandedContainers((prev) => new Set(prev).add(container.id)); + }; + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); const handleDragStart = (event: DragStartEvent) => { @@ -1548,7 +1609,9 @@ export default function MasksPanel() { handleDelete={handleDeleteContainer} handleDuplicate={handleDuplicateContainer} handleDuplicateAndInvert={handleDuplicateAndInvertContainer} - handleConvertToComponent={handleConvertContainerToComponent} + handleConvertToNewComponent={handleConvertContainerToNewComponent} + handleConvertToExistingComponent={handleConvertContainerToExistingComponent} + customMaskComponents={customMaskComponents} handlePasteMask={handlePasteMask} copyMaskToClipboard={copyMaskToClipboard} copiedMask={copiedMask} @@ -1770,7 +1833,9 @@ function ContainerRow({ handleDelete, handleDuplicate, handleDuplicateAndInvert, - handleConvertToComponent, + handleConvertToNewComponent, + handleConvertToExistingComponent, + customMaskComponents, handlePasteMask, copyMaskToClipboard, copiedMask, @@ -1839,6 +1904,24 @@ function ContainerRow({ return null; }) .filter(Boolean); + const convertToComponentSubmenu = [ + { + label: 'New Custom Component', + icon: Component, + onClick: () => handleConvertToNewComponent(container), + }, + ...(customMaskComponents.length + ? [ + { type: OPTION_SEPARATOR }, + ...customMaskComponents.map((component: CustomMaskComponent) => ({ + label: component.name, + icon: Component, + onClick: () => handleConvertToExistingComponent(container, component), + })), + ] + : []), + ]; + showContextMenu(e.clientX, e.clientY, [ { label: 'Rename', @@ -1854,7 +1937,7 @@ function ContainerRow({ label: 'Convert to Component', icon: Component, disabled: container.subMasks.length === 0, - onClick: () => handleConvertToComponent(container), + submenu: convertToComponentSubmenu, }, { label: 'Copy Mask', icon: Copy, onClick: () => copyMaskToClipboard(container) }, { From 926bd53ada738dc179cda4e5a945187c3efc675c Mon Sep 17 00:00:00 2001 From: Duecki Date: Wed, 13 May 2026 19:36:12 +0200 Subject: [PATCH 5/5] Fix nested custom components --- src/components/panel/right/MasksPanel.tsx | 78 +++++++++++++++++------ 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/src/components/panel/right/MasksPanel.tsx b/src/components/panel/right/MasksPanel.tsx index bf76b5a42..dd3240dc7 100644 --- a/src/components/panel/right/MasksPanel.tsx +++ b/src/components/panel/right/MasksPanel.tsx @@ -181,6 +181,41 @@ const cloneSubMaskTreeForCustomComponent = (subMasks: Array, customComp return [clonedSubMask]; }); +const syncComponentInstancesDeep = ( + subMasks: Array, + targetComponentId: string, + updatedData: Partial, +): Array => + subMasks.map((subMask) => { + let updatedSubMask = subMask; + + if ( + updatedSubMask.type === Mask.CustomComponent && + updatedSubMask.parameters?.customComponentId === targetComponentId + ) { + updatedSubMask = { + ...updatedSubMask, + name: updatedData.name ?? updatedSubMask.name, + parameters: { + ...updatedSubMask.parameters, + ...(updatedData.subMasks ? { subMasks: cloneSubMaskTree(updatedData.subMasks) } : {}), + }, + }; + } + + if (Array.isArray(updatedSubMask.parameters?.subMasks)) { + updatedSubMask = { + ...updatedSubMask, + parameters: { + ...updatedSubMask.parameters, + subMasks: syncComponentInstancesDeep(updatedSubMask.parameters.subMasks, targetComponentId, updatedData), + }, + }; + } + + return updatedSubMask; + }); + const createCustomComponentSubMask = ( component: CustomMaskComponent, mode: SubMaskMode = SubMaskMode.Additive, @@ -1098,15 +1133,26 @@ export default function MasksPanel() { return { ...prev, customMaskComponents: (prev.customMaskComponents || []).map((component) => - component.id === renamedCustomComponentId ? { ...component, name: renamedCustomComponentName } : component, + component.id === renamedCustomComponentId + ? { + ...component, + name: renamedCustomComponentName, + subMasks: syncComponentInstancesDeep(component.subMasks, renamedCustomComponentId, { + name: renamedCustomComponentName, + }), + } + : { + ...component, + subMasks: syncComponentInstancesDeep(component.subMasks, renamedCustomComponentId, { + name: renamedCustomComponentName, + }), + }, ), masks: masks.map((m) => ({ ...m, - subMasks: m.subMasks.map((sm) => - sm.type === Mask.CustomComponent && sm.parameters?.customComponentId === renamedCustomComponentId - ? { ...sm, name: renamedCustomComponentName } - : sm, - ), + subMasks: syncComponentInstancesDeep(m.subMasks, renamedCustomComponentId, { + name: renamedCustomComponentName, + }), })), }; }); @@ -1286,7 +1332,12 @@ export default function MasksPanel() { setAdjustments((prev: Adjustments) => ({ ...prev, customMaskComponents: (prev.customMaskComponents || []).map((customComponent) => - customComponent.id === component.id ? updatedComponent : customComponent, + customComponent.id === component.id + ? updatedComponent + : { + ...customComponent, + subMasks: syncComponentInstancesDeep(customComponent.subMasks, component.id, updatedComponent), + }, ), masks: prev.masks.map((maskContainer) => { if (maskContainer.id === container.id) { @@ -1295,18 +1346,7 @@ export default function MasksPanel() { return { ...maskContainer, - subMasks: maskContainer.subMasks.map((subMask) => - subMask.type === Mask.CustomComponent && subMask.parameters?.customComponentId === component.id - ? { - ...subMask, - name: updatedComponent.name, - parameters: { - ...subMask.parameters, - subMasks: cloneSubMaskTree(updatedComponent.subMasks), - }, - } - : subMask, - ), + subMasks: syncComponentInstancesDeep(maskContainer.subMasks, component.id, updatedComponent), }; }), }));