/** * Lossless, versioned editor document serialization. * * Draft autosave and downloaded project files must use this same boundary. * Keeping a second hand-written layer shape is how masks and adjustments were * previously dropped when a document was reopened. */ import { normalizeAdjustmentData } from './adjustment-layer.js'; import { normalizeEffect } from './effects.js'; import { normalizeMaskDensity, normalizeMaskFeather } from './mask-utils.js'; export const EDITOR_DOCUMENT_VERSION = 15; export const EDITOR_PROJECT_MAX_BYTES = 256 * 1024 * 1024; export const EDITOR_MAX_DIMENSION = 32768; export const EDITOR_MAX_DOCUMENT_PIXELS = 100_000_000; export const EDITOR_MAX_SURFACE_PIXELS = 300_000_000; export const EDITOR_MAX_LAYERS = 256; export const EDITOR_MAX_MASKS_PER_LAYER = 64; export const EDITOR_MAX_GROUPS = 128; export const EDITOR_MAX_SAVED_SELECTIONS = 64; const MAX_SERIALIZED_GUIDES = 1000; const SUPPORTED_BLEND_MODES = new Set([ 'source-over', 'multiply', 'screen', 'overlay', 'soft-light', 'hard-light', 'darken', 'lighten', 'color-dodge', 'color-burn', 'difference', 'exclusion', 'hue', 'saturation', 'color', 'luminosity', ]); function normalizeGuideValues(values, fallback = []) { const source = Array.isArray(values) ? values : fallback; const normalized = []; for (const raw of Array.isArray(source) ? source : []) { const value = Number(raw); if (!Number.isFinite(value) || value < 0) continue; if (!normalized.some(existing => Math.abs(existing - value) < 0.0001)) normalized.push(value); if (normalized.length >= MAX_SERIALIZED_GUIDES) break; } return normalized.sort((a, b) => a - b); } export function normalizeEditorView(view, fallback = {}) { const source = view && typeof view === 'object' ? view : {}; const fallbackGuides = fallback.guides && typeof fallback.guides === 'object' ? fallback.guides : { vertical: [], horizontal: [] }; const guides = source.guides && typeof source.guides === 'object' ? source.guides : {}; const rawGridSize = Number(source.gridSize ?? fallback.gridSize ?? 16); return { rulersVisible: Boolean(source.rulersVisible ?? fallback.rulersVisible ?? true), gridVisible: Boolean(source.gridVisible ?? fallback.gridVisible ?? false), gridSize: Math.max(2, Math.min(1000, Math.round(Number.isFinite(rawGridSize) ? rawGridSize : 16))), snapEnabled: Boolean(source.snapEnabled ?? fallback.snapEnabled ?? false), snapToGrid: Boolean(source.snapToGrid ?? fallback.snapToGrid ?? false), guides: { vertical: normalizeGuideValues(guides.vertical, fallbackGuides.vertical), horizontal: normalizeGuideValues(guides.horizontal, fallbackGuides.horizontal), }, }; } export function cloneDocumentValue(value, fallback) { try { return value == null ? fallback : JSON.parse(JSON.stringify(value)); } catch { return fallback; } } export class EditorDocumentError extends Error { constructor(message, code = 'invalid-document') { super(message); this.name = 'EditorDocumentError'; this.code = code; } } const MIGRATIONS = new Map([ [1, document => ({ ...document, v: 2, layers: (document.layers || []).map(layer => ({ visible: true, opacity: 1, locked: false, offset: { x: 0, y: 0 }, ...layer, dataUrl: layer?.dataUrl || layer?.dataURL || null, })), })], [2, document => ({ ...document, v: 3, layers: (document.layers || []).map(layer => ({ adjustments: {}, adjLayers: [], activeMaskId: null, masks: [], ...layer, })), })], [3, document => ({ ...document, v: 4, layers: (document.layers || []).map(layer => ({ kind: 'raster', text: null, ...layer, masks: (layer?.masks || []).map(mask => ({ mode: 'selection', ...mask, space: mask?.space || (mask?.mode === 'layer' ? 'layer' : 'document'), })), })), })], [4, document => ({ ...document, v: 5, view: normalizeEditorView(document.view), })], [5, document => ({ ...document, v: 6, groups: [], })], [6, document => ({ ...document, v: 7, layers: (document.layers || []).map(layer => ({ clipped: false, ...layer })), })], [7, document => ({ ...document, v: 8, groups: (document.groups || []).map(group => ({ parentId: null, ...group })), })], [8, document => ({ ...document, v: 9, groups: (document.groups || []).map(group => ({ masks: [], activeMaskId: null, ...group })), })], [9, document => ({ ...document, v: 10, layers: (document.layers || []).map(layer => ({ locks: { pixels: false, transparency: false, position: false }, ...layer, })), })], [10, document => ({ ...document, v: 11, savedSelections: [], })], [11, document => ({ ...document, v: 12, layers: (document.layers || []).map(layer => ({ ...layer, masks: (layer.masks || []).map(mask => mask?.mode === 'layer' ? { linked: true, offset: { x: 0, y: 0 }, ...mask, } : mask), })), })], [12, document => ({ ...document, v: 13, layers: (document.layers || []).map(layer => ({ placed: null, ...layer })), })], [13, document => ({ ...document, v: 14, layers: (document.layers || []).map(layer => ({ adjustment: null, ...layer })), })], [14, document => ({ ...document, v: 15, layers: (document.layers || []).map(layer => ({ effects: [], ...layer })), groups: (document.groups || []).map(group => ({ effects: [], ...group })), })], ]); function migrateEditorDocument(raw) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { throw new EditorDocumentError('Project root must be a JSON object.'); } const storedVersion = raw.v == null ? 1 : Number(raw.v); if (!Number.isInteger(storedVersion) || storedVersion < 1) { throw new EditorDocumentError('Project has an invalid document version.', 'invalid-version'); } if (storedVersion > EDITOR_DOCUMENT_VERSION) { throw new EditorDocumentError( `This project uses version ${storedVersion}; this editor supports up to version ${EDITOR_DOCUMENT_VERSION}.`, 'future-version', ); } let document = { ...raw }; while (document.v == null || document.v < EDITOR_DOCUMENT_VERSION) { const version = document.v == null ? 1 : document.v; const migrate = MIGRATIONS.get(version); if (!migrate) throw new EditorDocumentError(`No migration is available for project version ${version}.`, 'missing-migration'); document = migrate(document); } return { document, storedVersion }; } function positiveDimension(value, fallback = 0) { const numeric = Math.round(Number(value ?? fallback)); return Number.isFinite(numeric) && numeric > 0 && numeric <= EDITOR_MAX_DIMENSION ? numeric : null; } function isEmbeddedPng(value) { return typeof value === 'string' && value.startsWith('data:image/png;base64,'); } function finiteCoordinate(value, fallback = 0) { const numeric = Number(value); return Number.isFinite(numeric) && Math.abs(numeric) <= 1_000_000 ? numeric : fallback; } function normalizePlacedRecord(source) { if (!source || typeof source !== 'object' || Array.isArray(source)) return null; const sourceWidth = positiveDimension(source.sourceWidth); const sourceHeight = positiveDimension(source.sourceHeight); const matrix = Array.isArray(source.matrix) && source.matrix.length === 6 ? source.matrix.map(Number) : null; if (!sourceWidth || !sourceHeight || !matrix?.every(Number.isFinite) || !isEmbeddedPng(source.sourceDataUrl)) { return null; } if (matrix.some(value => Math.abs(value) > 1_000_000)) return null; return { sourceWidth, sourceHeight, sourceName: typeof source.sourceName === 'string' ? source.sourceName.slice(0, 200) : 'Placed image', sourceDataUrl: source.sourceDataUrl, matrix, }; } /** Migrate and validate untrusted draft/project JSON before allocating canvases. */ export function prepareEditorDocument(raw) { const { document: migrated, storedVersion } = migrateEditorDocument(raw); const warnings = []; const warn = message => { if (warnings.length < 50) warnings.push(message); }; const width = positiveDimension(migrated.imgWidth); const height = positiveDimension(migrated.imgHeight); if (!width || !height) { throw new EditorDocumentError(`Project dimensions must be between 1 and ${EDITOR_MAX_DIMENSION} pixels.`, 'invalid-dimensions'); } if (width * height > EDITOR_MAX_DOCUMENT_PIXELS) { throw new EditorDocumentError('Project canvas exceeds the 100 megapixel safety limit.', 'pixel-budget'); } if (!Array.isArray(migrated.layers)) { throw new EditorDocumentError('Project is missing its layer list.', 'invalid-layers'); } if (migrated.layers.length > EDITOR_MAX_LAYERS) { throw new EditorDocumentError(`Project exceeds the ${EDITOR_MAX_LAYERS}-layer limit.`, 'layer-budget'); } let surfacePixels = width * height; let embeddedCharacters = 0; let recoveredId = 1; const usedIds = new Set(); const layers = []; for (let index = 0; index < migrated.layers.length; index += 1) { const source = migrated.layers[index]; const label = typeof source?.name === 'string' && source.name.trim() ? source.name.trim().slice(0, 200) : `Layer ${index + 1}`; if (!source || typeof source !== 'object' || Array.isArray(source)) { warn(`${label} was skipped because its record is invalid.`); continue; } const canvasW = positiveDimension(source.canvasW, width); const canvasH = positiveDimension(source.canvasH, height); if (!canvasW || !canvasH) { warn(`${label} was skipped because its canvas dimensions are invalid.`); continue; } const textIsValid = source.kind === 'text' && source.text && typeof source.text === 'object' && !Array.isArray(source.text); const shapeIsValid = source.kind === 'shape' && source.shape && typeof source.shape === 'object' && !Array.isArray(source.shape); const adjustmentIsValid = source.kind === 'adjustment' && source.adjustment && typeof source.adjustment === 'object' && !Array.isArray(source.adjustment); const placed = source.kind === 'placed' ? normalizePlacedRecord(source.placed) : null; const placedIsValid = source.kind === 'placed' && !!placed; if (source.kind && source.kind !== 'raster' && source.kind !== 'text' && source.kind !== 'shape' && source.kind !== 'placed' && source.kind !== 'adjustment') { warn(`${label} used unsupported layer type "${String(source.kind).slice(0, 40)}" and was recovered as raster pixels.`); } else if (source.kind === 'text' && !textIsValid) { warn(`${label} had invalid editable text metadata and was recovered as raster pixels.`); } else if (source.kind === 'shape' && !shapeIsValid) { warn(`${label} had invalid editable shape metadata and was recovered as raster pixels.`); } else if (source.kind === 'adjustment' && !adjustmentIsValid) { warn(`${label} had invalid adjustment metadata and was recovered as raster pixels.`); } else if (source.kind === 'placed' && !placedIsValid) { warn(`${label} had a missing or corrupt placed source and was recovered from its raster preview.`); } if (!isEmbeddedPng(source.dataUrl) && !textIsValid && !shapeIsValid && !placedIsValid && !adjustmentIsValid) { warn(`${label} was skipped because its pixel data is missing or corrupt.`); continue; } if (!isEmbeddedPng(source.dataUrl) && textIsValid) { warn(`${label} had no valid preview pixels and will be rebuilt from its editable text data.`); } if (!isEmbeddedPng(source.dataUrl) && shapeIsValid) { warn(`${label} had no valid preview pixels and will be rebuilt from its editable shape data.`); } if (!isEmbeddedPng(source.dataUrl) && placedIsValid) { warn(`${label} had no valid preview pixels and will be rebuilt from its placed source.`); } embeddedCharacters += typeof source.dataUrl === 'string' ? source.dataUrl.length : 0; surfacePixels += canvasW * canvasH; if (placedIsValid) { embeddedCharacters += placed.sourceDataUrl.length; surfacePixels += placed.sourceWidth * placed.sourceHeight; } let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : ''; if (!id || usedIds.has(id)) { do { id = `layer-recovered-${recoveredId++}`; } while (usedIds.has(id)); warn(`${label} received a replacement layer id.`); } usedIds.add(id); const masks = []; const maskRecords = Array.isArray(source.masks) ? source.masks : []; if (maskRecords.length > EDITOR_MAX_MASKS_PER_LAYER) { warn(`${label} has more than ${EDITOR_MAX_MASKS_PER_LAYER} masks; extras were skipped.`); } for (let maskIndex = 0; maskIndex < Math.min(maskRecords.length, EDITOR_MAX_MASKS_PER_LAYER); maskIndex += 1) { const mask = maskRecords[maskIndex]; const maskLabel = typeof mask?.name === 'string' && mask.name.trim() ? mask.name.trim().slice(0, 200) : `Mask ${maskIndex + 1}`; const maskW = positiveDimension(mask?.canvasW, width); const maskH = positiveDimension(mask?.canvasH, height); if (!mask || typeof mask !== 'object' || !maskW || !maskH || !isEmbeddedPng(mask.dataUrl)) { warn(`${label} / ${maskLabel} was skipped because its mask data is invalid.`); continue; } embeddedCharacters += mask.dataUrl.length; surfacePixels += maskW * maskH; let maskId = typeof mask.id === 'string' && mask.id.trim() ? mask.id.trim().slice(0, 100) : ''; if (!maskId || usedIds.has(maskId)) { do { maskId = `mask-recovered-${recoveredId++}`; } while (usedIds.has(maskId)); warn(`${label} / ${maskLabel} received a replacement mask id.`); } usedIds.add(maskId); const mode = mask.mode === 'layer' ? 'layer' : 'selection'; masks.push({ ...mask, id: maskId, name: maskLabel, visible: mask.visible !== false, density: normalizeMaskDensity(mask.density), feather: normalizeMaskFeather(mask.feather), mode, space: mask.space === 'layer' || mask.space === 'document' ? mask.space : (mode === 'layer' ? 'layer' : 'document'), linked: mode === 'layer' ? mask.linked !== false : true, offset: mode === 'layer' ? { x: finiteCoordinate(mask.offset?.x), y: finiteCoordinate(mask.offset?.y), } : { x: 0, y: 0 }, canvasW: maskW, canvasH: maskH, }); } if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) { throw new EditorDocumentError('Project layers and masks exceed the safe in-memory pixel budget.', 'surface-budget'); } if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) { throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget'); } const offset = source.offset && typeof source.offset === 'object' ? source.offset : {}; const opacity = Number(source.opacity); const blendMode = SUPPORTED_BLEND_MODES.has(source.blendMode) ? source.blendMode : 'source-over'; if (source.blendMode && blendMode !== source.blendMode) { warn(`${label} used unsupported blend mode "${String(source.blendMode).slice(0, 40)}" and was reset to Normal.`); } layers.push({ ...source, id, name: label, visible: source.visible !== false, opacity: Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1, locked: !!source.locked, locks: { pixels: !!source.locks?.pixels, transparency: !!source.locks?.transparency, position: !!source.locks?.position, }, clipped: !!source.clipped, blendMode, kind: textIsValid ? 'text' : (shapeIsValid ? 'shape' : (placedIsValid ? 'placed' : (adjustmentIsValid ? 'adjustment' : 'raster'))), text: textIsValid ? source.text : null, shape: shapeIsValid ? source.shape : null, adjustment: adjustmentIsValid ? normalizeAdjustmentData(source.adjustment) : null, effects: Array.isArray(source.effects) ? source.effects.map(normalizeEffect) : [], placed: placedIsValid ? placed : null, canvasW, canvasH, offset: { x: finiteCoordinate(offset.x), y: finiteCoordinate(offset.y) }, masks, activeMaskId: masks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null, adjustments: source.adjustments && typeof source.adjustments === 'object' ? source.adjustments : {}, adjLayers: Array.isArray(source.adjLayers) ? source.adjLayers : [], }); } if (!layers.length) { throw new EditorDocumentError('No recoverable layers were found in this project.', 'no-layers'); } const activeLayerId = layers.some(layer => layer.id === migrated.activeLayerId) ? migrated.activeLayerId : layers[layers.length - 1].id; const groups = []; const claimedLayerIds = new Set(); const validLayerIds = new Set(layers.map(layer => layer.id)); const groupRecords = Array.isArray(migrated.groups) ? migrated.groups : []; if (groupRecords.length > EDITOR_MAX_GROUPS) { warn(`Project has more than ${EDITOR_MAX_GROUPS} groups; extras were skipped.`); } for (let index = 0; index < Math.min(groupRecords.length, EDITOR_MAX_GROUPS); index += 1) { const source = groupRecords[index]; if (!source || typeof source !== 'object' || Array.isArray(source)) { warn(`Group ${index + 1} was skipped because its record is invalid.`); continue; } const layerIds = []; for (const id of Array.isArray(source.layerIds) ? source.layerIds : []) { if (validLayerIds.has(id) && !claimedLayerIds.has(id)) { claimedLayerIds.add(id); layerIds.push(id); } } let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : ''; if (!id || usedIds.has(id)) { do { id = `group-recovered-${recoveredId++}`; } while (usedIds.has(id)); warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} received a replacement group id.`); } usedIds.add(id); const groupMasks = []; const groupMaskRecords = Array.isArray(source.masks) ? source.masks : []; if (groupMaskRecords.length > EDITOR_MAX_MASKS_PER_LAYER) { warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} has too many masks; extras were skipped.`); } for (let maskIndex = 0; maskIndex < Math.min(groupMaskRecords.length, EDITOR_MAX_MASKS_PER_LAYER); maskIndex += 1) { const mask = groupMaskRecords[maskIndex]; const maskLabel = typeof mask?.name === 'string' && mask.name.trim() ? mask.name.trim().slice(0, 200) : `Group Mask ${maskIndex + 1}`; const maskW = positiveDimension(mask?.canvasW, width); const maskH = positiveDimension(mask?.canvasH, height); if (!mask || typeof mask !== 'object' || !maskW || !maskH || !isEmbeddedPng(mask.dataUrl)) { warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} / ${maskLabel} was skipped because its mask data is invalid.`); continue; } embeddedCharacters += mask.dataUrl.length; surfacePixels += maskW * maskH; let maskId = typeof mask.id === 'string' && mask.id.trim() ? mask.id.trim().slice(0, 100) : ''; if (!maskId || usedIds.has(maskId)) { do { maskId = `mask-recovered-${recoveredId++}`; } while (usedIds.has(maskId)); warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} / ${maskLabel} received a replacement mask id.`); } usedIds.add(maskId); groupMasks.push({ id: maskId, name: maskLabel, visible: mask.visible !== false, density: normalizeMaskDensity(mask.density), feather: normalizeMaskFeather(mask.feather), mode: 'group', space: 'document', canvasW: maskW, canvasH: maskH, dataUrl: mask.dataUrl, }); } const opacity = Number(source.opacity); const blendMode = SUPPORTED_BLEND_MODES.has(source.blendMode) ? source.blendMode : 'source-over'; if (source.blendMode && blendMode !== source.blendMode) { warn(`${String(source.name || `Group ${index + 1}`).slice(0, 200)} used an unsupported blend mode and was reset to Normal.`); } groups.push({ id, name: String(source.name || `Group ${index + 1}`).trim().slice(0, 200) || `Group ${index + 1}`, layerIds, parentId: typeof source.parentId === 'string' && source.parentId.trim() ? source.parentId.trim().slice(0, 100) : null, visible: source.visible !== false, opacity: Number.isFinite(opacity) ? Math.max(0, Math.min(1, opacity)) : 1, blendMode, locked: !!source.locked, collapsed: !!source.collapsed, ...(Array.isArray(source.effects) && source.effects.length ? { effects: source.effects.map(serializeEffect) } : {}), masks: groupMasks, activeMaskId: groupMasks.some(mask => mask.id === source.activeMaskId) ? source.activeMaskId : null, }); } if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) { throw new EditorDocumentError('Project layers and masks exceed the safe in-memory pixel budget.', 'surface-budget'); } if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) { throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget'); } const savedSelections = []; const selectionRecords = Array.isArray(migrated.savedSelections) ? migrated.savedSelections : []; if (selectionRecords.length > EDITOR_MAX_SAVED_SELECTIONS) { warn(`Project has more than ${EDITOR_MAX_SAVED_SELECTIONS} saved selections; extras were skipped.`); } for (let index = 0; index < Math.min(selectionRecords.length, EDITOR_MAX_SAVED_SELECTIONS); index += 1) { const source = selectionRecords[index]; const name = typeof source?.name === 'string' && source.name.trim() ? source.name.trim().slice(0, 100) : `Selection ${index + 1}`; if (!source || typeof source !== 'object' || source.canvasW !== width || source.canvasH !== height || !isEmbeddedPng(source.dataUrl)) { warn(`${name} was skipped because its saved selection data is invalid or has the wrong dimensions.`); continue; } embeddedCharacters += source.dataUrl.length; surfacePixels += width * height; let id = typeof source.id === 'string' && source.id.trim() ? source.id.trim().slice(0, 100) : ''; if (!id || usedIds.has(id)) { do { id = `selection-recovered-${recoveredId++}`; } while (usedIds.has(id)); warn(`${name} received a replacement selection id.`); } usedIds.add(id); savedSelections.push({ id, name, canvasW: width, canvasH: height, dataUrl: source.dataUrl }); } if (surfacePixels > EDITOR_MAX_SURFACE_PIXELS) { throw new EditorDocumentError('Project layers, masks, and saved selections exceed the safe in-memory pixel budget.', 'surface-budget'); } if (embeddedCharacters > EDITOR_PROJECT_MAX_BYTES) { throw new EditorDocumentError('Project embedded image data exceeds the 256 MB safety limit.', 'data-budget'); } const validGroupIds = new Set(groups.map(group => group.id)); const groupsById = new Map(groups.map(group => [group.id, group])); for (const group of groups) { if (group.parentId === group.id || (group.parentId && !validGroupIds.has(group.parentId))) { warn(`${group.name} referenced a missing or invalid parent group and was moved to the top level.`); group.parentId = null; } } for (const group of groups) { const visited = new Set([group.id]); let cursor = group; while (cursor.parentId) { if (visited.has(cursor.parentId)) { warn(`${group.name} contained a cyclic group relationship and was moved to the top level.`); group.parentId = null; break; } visited.add(cursor.parentId); cursor = groupsById.get(cursor.parentId); if (!cursor) break; } } // A group may contain only child groups. Remove only empty leaves after the // complete parent graph is available, then repeat for newly empty parents. while (true) { const parentIds = new Set(groups.map(group => group.parentId).filter(Boolean)); const emptyIndex = groups.findIndex(group => !group.layerIds.length && !parentIds.has(group.id)); if (emptyIndex < 0) break; const [empty] = groups.splice(emptyIndex, 1); warn(`${empty.name} was skipped because it has no recoverable layers or child groups.`); } const scopeByLayer = new Map(); for (const group of groups) { for (const id of group.layerIds) scopeByLayer.set(id, group.id); } for (let index = 0; index < layers.length; index += 1) { const layer = layers[index]; if (!layer.clipped) continue; const previous = layers[index - 1]; const scope = scopeByLayer.get(layer.id) || null; if (!previous || (scopeByLayer.get(previous.id) || null) !== scope) { layer.clipped = false; warn(`${layer.name} had no clipping base in its layer scope and was released.`); } } if (storedVersion < EDITOR_DOCUMENT_VERSION) { warnings.unshift(`Project upgraded from version ${storedVersion} to ${EDITOR_DOCUMENT_VERSION}.`); } return { document: { ...migrated, v: EDITOR_DOCUMENT_VERSION, imgWidth: width, imgHeight: height, activeLayerId, view: normalizeEditorView(migrated.view), groups, savedSelections, layers, }, warnings, migratedFrom: storedVersion < EDITOR_DOCUMENT_VERSION ? storedVersion : null, }; } function canvasDataUrl(canvas) { if (!canvas || typeof canvas.toDataURL !== 'function') return null; try { return canvas.toDataURL('image/png'); } catch { return null; } } function serializeEffect(effect) { const normalized = normalizeEffect(effect); if (!normalized.mask) return normalized; return { ...normalized, mask: { id: normalized.mask.id, name: normalized.mask.name, visible: normalized.mask.visible !== false, canvasW: normalized.mask.canvas?.width || normalized.mask.canvasW || 0, canvasH: normalized.mask.canvas?.height || normalized.mask.canvasH || 0, dataUrl: canvasDataUrl(normalized.mask.canvas), }, }; } export function serializeEditorDocument(state, extras = {}) { return { v: EDITOR_DOCUMENT_VERSION, imageId: state.imageId || null, imgWidth: state.imgWidth, imgHeight: state.imgHeight, activeLayerId: state.activeLayerId || null, nextLayerId: state.nextLayerId, view: normalizeEditorView(state), ...extras, savedSelections: (state.savedSelections || []).slice(0, EDITOR_MAX_SAVED_SELECTIONS).map(selection => ({ id: selection.id, name: String(selection.name || 'Selection').slice(0, 100), canvasW: selection.canvas?.width || state.imgWidth || 0, canvasH: selection.canvas?.height || state.imgHeight || 0, dataUrl: canvasDataUrl(selection.canvas), })), groups: (state.layerGroups || []).map(group => ({ id: group.id, name: group.name, layerIds: [...(group.layerIds || [])], parentId: group.parentId || null, visible: group.visible !== false, opacity: typeof group.opacity === 'number' ? group.opacity : 1, blendMode: group.blendMode || 'source-over', locked: !!group.locked, collapsed: !!group.collapsed, activeMaskId: group.activeMaskId || null, ...(Array.isArray(group.effects) && group.effects.length ? { effects: group.effects.map(serializeEffect) } : {}), masks: (group.masks || []).map(mask => ({ id: mask.id, name: mask.name, visible: mask.visible !== false, density: normalizeMaskDensity(mask.density), feather: normalizeMaskFeather(mask.feather), mode: 'group', space: 'document', canvasW: mask.canvas?.width || state.imgWidth || 0, canvasH: mask.canvas?.height || state.imgHeight || 0, dataUrl: canvasDataUrl(mask.canvas), })), })), layers: (state.layers || []).map(layer => ({ id: layer.id, name: layer.name, visible: layer.visible !== false, opacity: typeof layer.opacity === 'number' ? layer.opacity : 1, locked: !!layer.locked, locks: { pixels: !!layer.locks?.pixels, transparency: !!layer.locks?.transparency, position: !!layer.locks?.position, }, clipped: !!layer.clipped, isBase: !!layer.isBase, blendMode: layer.blendMode || 'source-over', kind: layer.kind || 'raster', text: cloneDocumentValue(layer.text, null), shape: cloneDocumentValue(layer.shape, null), adjustment: cloneDocumentValue(layer.adjustment, null), effects: Array.isArray(layer.effects) ? layer.effects.map(serializeEffect) : [], placed: layer.kind === 'placed' && layer.placed?.sourceCanvas ? { sourceWidth: layer.placed.sourceWidth || layer.placed.sourceCanvas.width, sourceHeight: layer.placed.sourceHeight || layer.placed.sourceCanvas.height, sourceName: String(layer.placed.sourceName || 'Placed image').slice(0, 200), sourceDataUrl: canvasDataUrl(layer.placed.sourceCanvas), matrix: Array.isArray(layer.placed.matrix) ? layer.placed.matrix.map(Number) : [1, 0, 0, 1, 0, 0], } : null, canvasW: layer.canvas?.width || 0, canvasH: layer.canvas?.height || 0, offset: { ...(state.layerOffsets?.get(layer.id) || { x: 0, y: 0 }) }, dataUrl: canvasDataUrl(layer.canvas), adjustments: cloneDocumentValue(layer.adjustments, {}), adjLayers: cloneDocumentValue(layer.adjLayers, []), activeMaskId: layer.activeMaskId || null, masks: (layer.masks || []).map(mask => ({ id: mask.id, name: mask.name, visible: mask.visible !== false, density: normalizeMaskDensity(mask.density), feather: normalizeMaskFeather(mask.feather), // Existing masks are AI/selection regions. A future true layer mask // uses mode="layer" without changing old saved documents. mode: mask.mode || 'selection', space: mask.space || (mask.mode === 'layer' ? 'layer' : 'document'), linked: mask.mode === 'layer' ? mask.linked !== false : true, offset: mask.mode === 'layer' ? { x: finiteCoordinate(mask.offset?.x), y: finiteCoordinate(mask.offset?.y) } : { x: 0, y: 0 }, canvasW: mask.canvas?.width || state.imgWidth || 0, canvasH: mask.canvas?.height || state.imgHeight || 0, dataUrl: canvasDataUrl(mask.canvas), })), })), }; } export function nextLayerIdFromDocument(data) { const stored = Number(data?.nextLayerId); if (Number.isInteger(stored) && stored > 0) return stored; let max = 0; for (const layer of data?.layers || []) { const match = String(layer?.id || '').match(/(\d+)$/); if (match) max = Math.max(max, Number(match[1])); for (const mask of layer?.masks || []) { const maskMatch = String(mask?.id || '').match(/(\d+)$/); if (maskMatch) max = Math.max(max, Number(maskMatch[1])); } } for (const group of data?.groups || []) { const match = String(group?.id || '').match(/(\d+)$/); if (match) max = Math.max(max, Number(match[1])); for (const mask of group?.masks || []) { const maskMatch = String(mask?.id || '').match(/(\d+)$/); if (maskMatch) max = Math.max(max, Number(maskMatch[1])); } } return max + 1; }