
무료 포토샵 개발기
구조도 저장한다
무료 포토샵을 만드는 포토샷 개발기입니다. 20편에서 PSD·PSB로 왕복한 AI 인물 문서에 그룹과 흰색 테두리를 더했습니다. 이번에는 한 장의 결과 이미지 뒤에 남아야 할 관계와 설정을 확인합니다.
AI 생성 시연용 사진. 편집 화면과 출력 이미지는 실제 포토샷에서 만들었습니다.
출처에서 시작하다.
그룹은 이름표보다 많은 일을 한다
레이어의 groupId와 그룹의 parentId는 문서 안의 연결 관계입니다. layer-tree.ts는 상위 그룹을 따라가며 순환을 막고, 중복 ID와 사라진 부모도 검사합니다. 눈에 보이는 들여쓰기만 저장해서는 같은 문서를 복원할 수 없습니다.
자식의 표시 상태가 켜져 있어도 부모가 숨겨져 있으면 표시되지 않습니다. 부모가 잠겨 있으면 자식도 해당 편집에서 잠깁니다. 그룹의 합성 모드와 불투명도는 별도의 속성입니다. Pass Through와 Normal을 같은 뜻으로 처리하지 않는 이유가 여기에 있습니다.
스타일은 픽셀에 굽기 전에 설정으로 남긴다
이번 시연에서는 Portrait base에 흰색 Stroke를 24px, Inside, 불투명도 100%로 적용했습니다. 원본 사진을 직접 덮어 쓰지 않고 효과 설정을 문서에 남기므로 나중에 크기나 위치를 다시 조정할 수 있습니다.
layer-style-commands.ts는 효과를 복사할 때 구조화된 사본과 원래 형식의 효과 정보를 함께 다룹니다. 잠긴 레이어와 조정 레이어의 적용 가능 여부도 검사합니다. 보관한 정보가 많다는 것과 모든 Adobe 효과 조합이 동일하게 렌더링된다는 주장은 구분해야 합니다.
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);}계산의
안쪽으로.
Blend If는 네 개의 경계와 채널을 검사한다
blend-if.ts의 한 범위는 네 값으로 구성되며, 각 값은 0~255 안에서 오름차순이어야 합니다. 분리한 경계 사이에는 연속적인 가중치가 생깁니다. 소스와 아래쪽 영상의 조건을 함께 계산하므로 단순한 불투명도 슬라이더와 다릅니다.
이번 인물에는 Blend If를 새로 적용하지 않았습니다. 보관된 precision-blend-if 보고서의 18개 PSD·PSB 사례 통과 기록을 확인했고, 이는 RGB·CMYK·Lab·Gray의 지정된 시험 조합에 대한 결과입니다. 오늘 이 보고서를 다시 실행했다거나 모든 사진에서 동일하다고 확대하지 않습니다.
잠금과 두 컴프를 실제 화면에서 확인했다
2026년 10월 22일 Hidden backup 60만 Portrait study 그룹에 넣고, 그룹을 Normal·85%로 설정했습니다. 이 그룹을 잠그자 자식의 X·Y·크기·회전 입력이 비활성화됐습니다. 그룹 바깥 Portrait base에는 흰색 테두리를 두었습니다.
White frame과 Grouped preview 두 컴프를 만들고 표시 상태를 전환했습니다. 캡처한 항목은 표시·위치·외형입니다. White frame을 적용하면 테두리 있는 기본 레이어가 나타나고 복제 레이어는 숨겨집니다. 잠금은 컴프의 캡처 항목이 아니어서 그룹 잠금은 그대로 남았습니다.
코드를 읽다.
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;})};편집 화면에서.

다시 열고, 보존 범위를 좁혀서 말한다
프로젝트를 .layerstudio로 저장하고 다시 열어 그룹 관계, 잠금, 85% 불투명도, 24px 안쪽 테두리와 두 컴프를 확인했습니다. 이번 직접 검사는 포토샷 프로젝트 왕복입니다. 그룹 마스크나 모든 효과를 새로 PSD·PSB 왕복했다는 뜻은 아닙니다.
기존 precision-groups-adjustments 보고서는 지정된 고정밀 그룹·마스크·조정 사례를 통과로 기록하며, 이 글에서는 과거 결과로만 인용합니다. 인물은 20편과 같은 AI 생성 모델, 작업 화면은 실제 캡처입니다. 22편은 선택 영역·마스크·채널을 이어서 다룹니다.
원본과 레이어 컴프.


AI 생성 시연용 사진. 편집 화면과 출력 이미지는 실제 포토샷에서 만들었습니다.