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..7e0bddb3a 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 = + CustomComponentParameters::deserialize(&self.parameters).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 = + CustomComponentParameters::deserialize(params_value).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..65a81bcb7 100644 --- a/src/components/panel/Editor.tsx +++ b/src/components/panel/Editor.tsx @@ -58,6 +58,19 @@ const checkCropValid = (pixelCrop: Partial, imageW: number, imageH: number return true; }; +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 as Array); + } + + return { ...subMask, parameters: cleanParams }; + }); + interface WgpuRenderState { useWgpuRenderer: boolean | undefined; isReady: boolean; @@ -1266,13 +1279,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..dd3240dc7 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,97 @@ 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 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 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, +): 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 +743,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 getNextCustomComponentName = () => { + const normalizedBase = 'Custom Component'; + const usedNames = new Set(customMaskComponents.map((component) => component.name.toLowerCase())); + let suffix = 1; + let candidate = `${normalizedBase} ${suffix}`; + + while (usedNames.has(candidate.toLowerCase())) { + suffix += 1; + candidate = `${normalizedBase} ${suffix}`; + } + + return candidate; + }; + useEffect(() => { let timer: ReturnType | null = null; if (isGeneratingAiMask && isAiMask) { @@ -734,12 +843,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 +939,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 +994,28 @@ export default function MasksPanel() { handleGridClick(type, 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(); 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 +1032,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 +1042,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 +1055,21 @@ 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), }; - }), - }); + }); + + const customComponentSubmenu = buildCustomComponentSubmenu(targetContainerId, mode); + if (customComponentSubmenu) { + submenu.push(customComponentSubmenu); + } + + return { + label, + icon, + submenu, + }; + }; const options: any[] = buildMenu( MASK_PANEL_CREATION_TYPES.filter((m) => m.id !== 'others'), @@ -909,6 +1083,10 @@ export default function MasksPanel() { submenu: buildMenu(OTHERS_MASK_TYPES, SubMaskMode.Additive), }); } + const customComponentSubmenu = buildCustomComponentSubmenu(targetContainerId, SubMaskMode.Additive); + if (customComponentSubmenu) { + options.push(customComponentSubmenu); + } if (targetContainerId && hasComponents) { options.push( @@ -927,13 +1105,57 @@ 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, + subMasks: syncComponentInstancesDeep(component.subMasks, renamedCustomComponentId, { + name: renamedCustomComponentName, + }), + } + : { + ...component, + subMasks: syncComponentInstancesDeep(component.subMasks, renamedCustomComponentId, { + name: renamedCustomComponentName, + }), + }, + ), + masks: masks.map((m) => ({ + ...m, + subMasks: syncComponentInstancesDeep(m.subMasks, renamedCustomComponentId, { + name: renamedCustomComponentName, + }), + })), + }; + }); const handleDeleteContainer = (id: string) => { if (activeMaskContainerId === id) handleDeselect(); @@ -959,24 +1181,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 +1290,72 @@ export default function MasksPanel() { insertSubMaskIntoContainer(containerId, pastedSubMask, insertIndex); }; + const handleConvertContainerToNewComponent = (container: MaskContainer) => { + if (!container.subMasks.length) { + return; + } + + const name = getNextCustomComponentName(); + 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); + setRenamingId(mergedSubMask.id); + setTempName(name); + 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, + subMasks: syncComponentInstancesDeep(customComponent.subMasks, component.id, updatedComponent), + }, + ), + masks: prev.masks.map((maskContainer) => { + if (maskContainer.id === container.id) { + return { ...maskContainer, subMasks: [mergedSubMask] }; + } + + return { + ...maskContainer, + subMasks: syncComponentInstancesDeep(maskContainer.subMasks, component.id, updatedComponent), + }; + }), + })); + + 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 +1375,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 +1499,15 @@ 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!), })); + 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 }, @@ -1316,7 +1595,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 +1649,9 @@ export default function MasksPanel() { handleDelete={handleDeleteContainer} handleDuplicate={handleDuplicateContainer} handleDuplicateAndInvert={handleDuplicateAndInvertContainer} + handleConvertToNewComponent={handleConvertContainerToNewComponent} + handleConvertToExistingComponent={handleConvertContainerToExistingComponent} + customMaskComponents={customMaskComponents} handlePasteMask={handlePasteMask} copyMaskToClipboard={copyMaskToClipboard} copiedMask={copiedMask} @@ -1591,6 +1873,9 @@ function ContainerRow({ handleDelete, handleDuplicate, handleDuplicateAndInvert, + handleConvertToNewComponent, + handleConvertToExistingComponent, + customMaskComponents, handlePasteMask, copyMaskToClipboard, copiedMask, @@ -1659,6 +1944,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', @@ -1670,6 +1973,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, + submenu: convertToComponentSubmenu, + }, { label: 'Copy Mask', icon: Copy, onClick: () => copyMaskToClipboard(container) }, { label: 'Paste Mask', @@ -1908,7 +2217,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 +2517,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 || {}),