선택을 다시 쓰다 / original
DEVELOPMENT JOURNAL / 22
Photoshot
CODE × UI
무료 포토샵 개발기

선택을 다시 쓰다

무료 포토샵을 만드는 포토샷 개발기입니다. 이번에는 선택 영역을 한 번 쓰고 버리지 않고 이름 있는 채널로 보관합니다. 새 AI 인물 사진에 사각 선택을 만들고, 저장·불러오기·마스크·프로젝트 재열기까지 연결했습니다.

PHOTOSHOT DEVELOPMENT JOURNAL / 22

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

Photoshot무료 포토샵 개발기 / 22FOUNDATION

출처에서 시작하다.

점선 안팎만 있는 것이 아니다

선택 영역은 폭·높이와 픽셀마다의 알파 값으로 표현됩니다. 0과 255 사이의 값이 있으면 경계가 부분적으로 선택된 상태를 남길 수 있습니다. 점선은 조작을 돕는 표시이며, 모든 경계 수치를 보여 주지는 않습니다.

selection-tools.ts의 추가 연산은 두 값의 최댓값, 빼기는 0 아래로 내려가지 않는 차이, 교차는 최솟값을 사용합니다. 서로 크기가 다른 선택은 오류로 막습니다. 조합 버튼의 이름뿐 아니라 중간값이 어떻게 합쳐지는지 확인해야 합니다.

선택·채널·마스크의 역할을 나눈다

활성 선택은 다음 편집에 적용할 범위이고, 저장 채널은 그 범위를 나중에 다시 불러올 수 있는 보관본입니다. 레이어 마스크는 특정 레이어의 보이는 정도를 조절합니다. 같은 알파 데이터를 활용해도 세 가지의 역할과 수명은 다릅니다.

selection-mask.ts는 선택 알파에서 마스크를 만들고, 이미 활성 마스크가 있으면 destination-in으로 결합합니다. 원본 이미지의 픽셀을 지워 버리는 경로와 구분됩니다. 마스크를 끄거나 수정할 여지를 남기는 것이 비파괴 편집의 핵심입니다.

app/studio/selection-tools.ts · SHA-256 83db06829467 · L3–5포토샷 구현 코드 ↗
export type SelectionOperation='replace'|'add'|'subtract'|'intersect';
export function combineSelection(a:PixelSelection|undefined,b:PixelSelection,op:SelectionOperation):PixelSelection{if(!a||op==='replace')return b;if(a.width!==b.width||a.height!==b.height)throw Error('Selection size mismatch.');return {...b,alpha:b.alpha.map((v,i)=>op==='add'?Math.max(a.alpha[i],v):op==='subtract'?Math.max(0,a.alpha[i]-v):Math.min(a.alpha[i],v))};}
export function shapeSelection(w:number,h:number,points:Point[],ellipse=false,feather=0):PixelSelection{const c=surface(w,h),g=c.getContext('2d')!;g.fillStyle='#fff';g.beginPath();if(ellipse&&points.length>=2){const [a,b]=points;g.ellipse((a.x+b.x)/2,(a.y+b.y)/2,Math.abs(b.x-a.x)/2,Math.abs(b.y-a.y)/2,0,0,Math.PI*2);}else{points.forEach((p,i)=>i?g.lineTo(p.x,p.y):g.moveTo(p.x,p.y));g.closePath();}g.fill();const p=g.getImageData(0,0,w,h).data;let s={width:w,height:h,alpha:Array.from({length:w*h},(_,i)=>p[i*4+3])};if(feather)s=modifySelection(s,'feather',feather);return s;}
Photoshot무료 포토샵 개발기 / 22IMPLEMENTATION

계산의
안쪽으로.

채널 연산과 벡터 경로는 별도 검증이 필요하다

channel-operations.ts는 색 공간에 맞는 채널 이름을 정하고, 합성 방식·불투명도·배율·오프셋을 검사합니다. add와 subtract에는 명시적인 계산이 있고 결과를 범위 안으로 제한합니다. 저장 알파 채널을 만들었다고 이런 연산 전체를 검증한 것은 아닙니다.

벡터 경로는 좌표와 곡선 정보를 보관합니다. psd-paths.ts는 경로 레코드 길이와 앵커 수를 검사하고 좌표를 문서 크기에 맞춰 해석합니다. 벡터를 픽셀 마스크로 바꿀 때의 해상도와 경계는 별도 문제입니다. 이번 시연에서 벡터 마스크까지 시험했다고 쓰지 않습니다.

12px 페더 선택을 저장하고 다시 불러왔다

2026년 10월 22일 1536×1024 AI 원본에서 인물이 있는 사각 범위를 선택하고 Feather를 12px로 설정했습니다. Select → Save Selection으로 Portrait window를 저장한 뒤 선택을 해제했습니다. Load Selection의 New Selection으로 같은 채널을 다시 불러왔습니다.

이 선택을 Layer Mask from Selection으로 연결하자 바깥은 투명해지고 경계는 부드럽게 남았습니다. 채널 화면에서도 흰 영역과 검은 바깥 사이의 회색 경계를 확인했습니다. 머리카락 자동 분리 예시가 아니라, 사각 선택 데이터를 재사용하는 실제 작업입니다.

PHOTOSHOT / SOURCE NOTES22

코드를 읽다.

app/studio/selection-mask.ts · SHA-256 7a08d70ef013 · L3–5포토샷 구현 코드 ↗
export function refineSelection(alpha:Uint8Array,w:number,h:number,feather:number){const c=surface(w,h),g=c.getContext('2d')!,im=g.createImageData(w,h);for(let i=0;i<alpha.length;i++){im.data[i*4]=im.data[i*4+1]=im.data[i*4+2]=255;im.data[i*4+3]=alpha[i];}g.putImageData(im,0,0);if(feather>0){const out=surface(w,h),ctx=out.getContext('2d')!;ctx.filter=`blur(${feather}px)`;ctx.drawImage(c,0,0);return out;}return c;}
export function selectionMask(alpha:Uint8Array,w:number,h:number,width:number,height:number,feather:number,existing?:LayerMask):LayerMask{const c=refineSelection(alpha,w,h,feather),g=c.getContext('2d')!;if(existing?.enabled){g.globalCompositeOperation='destination-in';g.drawImage(maskSurface(existing,w,h),0,0);}
 const data=g.getImageData(0,0,w,h).data;return{width,height,enabled:true,inverted:false,strokes:[],raster:{width:w,height:h,alpha:Array.from({length:w*h},(_,i)=>data[i*4+3])}};}
app/studio/channel-operations.ts · SHA-256 b3df72a570fa · L13–18포토샷 구현 코드 ↗
export function mixChannel(a:number,b:number,o:ChannelMix){
 let v=o.blend==='add'?(a+b)/o.scale+o.offset:o.blend==='subtract'?(a-b)/o.scale+o.offset:blendRGB([a/255,a/255,a/255],[b/255,b/255,b/255],o.blend as BlendMode,[0,0,0])[0]*255;
 v=Math.max(0,Math.min(255,v));return a*(1-o.opacity)+v*o.opacity;
}
function validateMix(o:ChannelMix){if(!channelBlendNames.includes(o.blend)||!Number.isFinite(o.opacity)||o.opacity<0||o.opacity>1||!Number.isFinite(o.scale)||o.scale<1||o.scale>2||!Number.isFinite(o.offset)||o.offset< -255||o.offset>255)throw Error('Invalid channel blending settings.');}
export const componentNames=(doc:StudioDoc)=>doc.precision?.space==='CMYK'?['Cyan','Magenta','Yellow','Black']:doc.precision?.space==='Lab'?['Lightness','a','b']:doc.precision?.space==='Gray'?['Gray']:['Red','Green','Blue'];
app/studio/psd-paths.ts · SHA-256 608cd40466ce · L12–16포토샷 구현 코드 ↗
export function readPsdPaths(bytes:Uint8Array,width:number,height:number):WorkPath[]{
 const output:WorkPath[]=[];for(const block of resourceBlocks(bytes)){if(!(block.id>=2000&&block.id<=2997||block.id===1025))continue;if(output.length>=100)throw Error('PSD contains more than 100 saved paths.');const data=block.data;if(data.length%26)throw Error('Invalid Photoshop path resource length.');const v=new DataView(data.buffer,data.byteOffset,data.byteLength),paths:PathShape[]=[];let path:PathShape|undefined,count=0;
 for(let at=0;at<data.length;at+=26){const selector=v.getUint16(at);if(selector===0||selector===3){if(path&&path.knots.length!==count)throw Error('Incomplete Photoshop path.');count=v.getUint16(at+2);path={open:selector===3,operation:operations[v.getInt16(at+4)]??'exclude',fillRule:v.getUint16(at+6)===2?'non-zero':'even-odd',knots:[]};paths.push(path);}else if([1,2,4,5].includes(selector)){if(!path||path.knots.length>=count)throw Error('Unexpected Photoshop path anchor.');const points:number[]=[];for(const j of [2,1,0])points.push(v.getInt32(at+6+j*8)/16777216*width,v.getInt32(at+2+j*8)/16777216*height);path.knots.push({linked:selector===1||selector===4,points});}else if(![6,7,8].includes(selector))throw Error('Unsupported Photoshop path record.');}
 if(path&&path.knots.length!==count)throw Error('Incomplete Photoshop path.');const nameBytes=bytes.subarray(block.start+7,block.start+7+bytes[block.start+6]);let name:string;try{name=new TextDecoder('utf-8',{fatal:true}).decode(nameBytes);}catch{try{name=new TextDecoder('euc-kr',{fatal:true}).decode(nameBytes);}catch{name=new TextDecoder('windows-1252').decode(nameBytes);}}output.push({id:uid(),name:name||'Work Path',paths});}
 validatePaths(output);return output;
Photoshot무료 포토샵 개발기 / 22IN PRACTICE

편집 화면에서.

선택을 다시 쓰다 / workspace
실제 포토샷 작업 화면. 설정과 사진을 함께 확인할 수 있습니다.

재열기 전후 알파 데이터까지 대조했다

프로젝트를 .layerstudio로 저장하고 다시 열었습니다. Portrait window가 남아 있는 것을 화면에서 확인하고 재저장한 파일도 비교했습니다. 저장 선택과 레이어 마스크 데이터가 각각 정확히 일치했으며, 마스크에는 0~255의 256가지 값이 있었습니다.

보관된 spot-channels-render와 spot-channels-native 보고서에는 각각 지정된 8·16비트 PSD/PSB 여덟 사례의 통과 기록이 있습니다. 이번에 새로 실행한 별색·Adobe 검사는 아닙니다. 인물은 AI 생성 모델이고 작업 화면은 실제 캡처입니다. 23편은 문자·텍스트 경로·OpenType의 편집 범위를 다룹니다.

Photoshot무료 포토샵 개발기 / 22BEFORE / AFTER

원본과 마스크 재열기.

선택을 다시 쓰다 / original
01 / AI 생성 시연 원본
선택을 다시 쓰다 / result
02 / 프로젝트 재열기 · 사각 선택과 부드러운 마스크

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