
Building a free Photoshop alternative
Save the structure
We continued the AI portrait document from Part 20 and added a group and a white stroke. This Photoshot journal checks the relationships and settings that must survive behind the visible composite.
AI-generated study photograph. Editor captures and outputs show Photoshot in use.
From the source.
Groups carry document relationships
A layer’s groupId and a group’s parentId connect the document. layer-tree.ts follows ancestors, rejects cycles, and validates duplicate IDs and missing parents. Saving only the visible indentation would not reconstruct the same document.
A visible child is still hidden by an invisible parent. A locked ancestor also restricts the child’s corresponding edits. Group opacity and blending are separate properties; Pass Through and Normal are not interchangeable labels.
Keep a style editable before baking pixels
The demonstration applies a white Stroke to Portrait base: 24px, Inside, 100% opacity. Its settings remain in the document instead of overwriting the original photograph, allowing later adjustments.
layer-style-commands.ts handles structured effect copies together with original-format effect information. It checks locked layers and adjustment-layer applicability. Retaining information does not establish identical rendering for every Adobe effect combination.
export function ancestors(doc:StudioDoc,groupId?:string){const result:LayerGroup[]=[],seen=new Set<string>();while(groupId){if(seen.has(groupId))throw Error('A group cannot contain itself.');seen.add(groupId);const g=doc.groups?.find(v=>v.id===groupId);if(!g)break;result.push(g);groupId=g.parentId;}return result;}
export function layerVisible(doc:StudioDoc,l:Layer){return l.visible&&ancestors(doc,l.groupId).every(g=>g.visible);}
export function layerLocked(doc:StudioDoc,l:Layer,kind:'all'|'position'|'pixels'='all'){return l.locked||ancestors(doc,l.groupId).some(g=>g.locked)||(kind==='position'?!!l.lockPosition:kind==='pixels'?!!l.lockPixels:false);}Inside the
calculation.
Blend If validates four bounds and channels
A range in blend-if.ts has four ascending values between 0 and 255. Split bounds create continuous weighting ramps. Conditions on the source and underlying image combine, making this different from a single opacity control.
No new Blend If setting was applied to this portrait. The stored precision-blend-if report records 18 passing PSD/PSB cases for specified RGB, CMYK, Lab and Gray combinations. We reviewed that report; we did not rerun it today or generalize it to every photograph.
Locking and two comps were checked in the UI
On October 22, 2026, only Hidden backup 60 was placed in Portrait study, set to Normal at 85%. Locking the group disabled the child’s X, Y, size and rotation controls. Portrait base remained outside the group with its white stroke.
White frame and Grouped preview capture visibility, position and appearance. Applying White frame restores the visible framed base and hides the duplicate. Locking is outside the comp’s capture fields, so the group remained locked when the comp changed.
Read the code.
import {validateEffects,type LayerEffects} from './layer-effects.ts';
export type StyleSnapshot={effects:LayerEffects;nativeEffects?:string;original?:string};
export function hasLayerStyle(layer?:Layer){return !!layer&&(Object.keys(layer.effects??{}).length>0||!!layer.psdData?.effects);}
export function validateStyleSnapshot(value:StyleSnapshot){
if(!value||typeof value!=='object')throw Error('Missing copied layer style.');
validateEffects(value.effects);export function validateBlendIf(b:BlendIf){if(!b||typeof b.enabled!=='boolean'||!['gray','red','green','blue','cyan','magenta','yellow','black','lightness','a','b'].includes(b.channel))throw Error('Invalid Blend If settings.');for(const a of [b.source,b.underlying])if(!Array.isArray(a)||a.length!==4||a.some((v,i)=>!Number.isFinite(v)||v<0||v>255||(i>0&&v<a[i-1])))throw Error('Invalid Blend If range.');if(b.additional){if(!Array.isArray(b.additional)||b.additional.length>4||new Set([b.channel,...b.additional.map(r=>r.channel)]).size!==b.additional.length+1)throw Error('Invalid additional Blend If channels.');for(const range of b.additional)validateBlendIf({...range,enabled:true,additional:undefined});}}
// The two halves define a continuous ramp; an unsplit endpoint remains fully included.
export function tonalWeight(v:number,r:TonalRange){const [a,b,c,d]=r;return (v<a?0:v<b?(v-a)/(b-a):1)*(v>d?0:v>c?(d-v)/(d-c):1);}export type CompLayer = Pick<Layer,'id'> & Partial<Pick<Layer,'visible'|'x'|'y'|'width'|'height'|'rotation'|'transformQuad'|'flipX'|'flipY'|'opacity'|'fill'|'blend'|'effects'>>;
export type CompGroup = Pick<LayerGroup,'id'> & Partial<Pick<LayerGroup,'visible'|'opacity'|'fill'|'blend'|'effects'>>;
export type LayerComp = {id:string;name:string;comment?:string;capturedInfo?:number;nativeId?:number;groups?:CompGroup[];layers:CompLayer[]};
export const VISIBILITY=1,POSITION=2,APPEARANCE=4;
const positionKeys=['x','y','width','height','rotation','transformQuad','flipX','flipY'] as const;
const appearanceKeys=['opacity','fill','blend','effects'] as const;
export function captureComp(doc:StudioDoc,name:string,flags=7,comment='',previous?:LayerComp):LayerComp {
if(!Number.isInteger(flags)||flags<1||flags>7)throw Error('Choose at least one property to capture.');
return {id:previous?.id??uid(),nativeId:previous?.nativeId,name:name.trim()||'Layer Comp',comment,capturedInfo:flags,
layers:doc.layers.map(l=>{const s:CompLayer={id:l.id};if(flags&VISIBILITY)s.visible=l.visible;if(flags&POSITION)for(const k of positionKeys)(s as any)[k]=structuredClone(l[k]);if(flags&APPEARANCE)for(const k of appearanceKeys)(s as any)[k]=structuredClone(l[k]);return s;}),
groups:doc.groups?.map(g=>{const s:CompGroup={id:g.id};if(flags&VISIBILITY)s.visible=g.visible;if(flags&APPEARANCE)for(const k of appearanceKeys)(s as any)[k]=structuredClone(g[k]);return s;})};In the editor.

Reopen and describe the actual scope
Saving and reopening .layerstudio preserved the group relationship, lock, 85% opacity, 24px inside stroke and both comps. This direct check used Photoshot’s project format. It was not a new PSD/PSB round trip for group masks or every effect.
The stored precision-groups-adjustments report records passing designated precision group, mask and adjustment cases; it is cited as historical evidence only. The model is the same AI-generated portrait as Part 20 and the workspace images are actual captures. Part 22 continues with selections, masks and channels.
Original and layer comps.


AI-generated study photograph. Editor captures and outputs show Photoshot in use.