Save the structure / original
DEVELOPMENT JOURNAL / 21
Photoshot
CODE × UI
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.

PHOTOSHOT DEVELOPMENT JOURNAL / 21

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

PhotoshotDEVELOPMENT JOURNAL / 21FOUNDATION

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.

app/studio/layer-tree.ts · SHA-256 f5427be278b1 · L4–6Photoshot implementation ↗
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);}
PhotoshotDEVELOPMENT JOURNAL / 21IMPLEMENTATION

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.

PHOTOSHOT / SOURCE NOTES21

Read the code.

app/studio/layer-style-commands.ts · SHA-256 8175351406d7 · L3–9Photoshot implementation ↗
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);
app/studio/blend-if.ts · SHA-256 a949acdee8cf · L8–10Photoshot implementation ↗
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);}
app/studio/layer-comps.ts · SHA-256 a973ae40b069 · L6–16Photoshot implementation ↗
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;})};
PhotoshotDEVELOPMENT JOURNAL / 21IN PRACTICE

In the editor.

Save the structure / workspace
Actual Photoshot workspace. Settings and the image are shown together.

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.

PhotoshotDEVELOPMENT JOURNAL / 21BEFORE / AFTER

Original and layer comps.

Save the structure / original
01 / AI-generated demonstration original
Save the structure / result
02 / Project reopened · stroke and both comps retained

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