
무료 포토샵 개발기
선은 작은 팁의 연속
무료 포토샵을 만드는 포토샷 개발기입니다. 24편의 AI 원본을 다시 열어 별도의 페인트 레이어에 두 선을 그렸습니다. 같은 크기의 브러시라도 팁 사이 간격에 따라 이어진 선과 점선으로 달라지는 과정을 확인합니다.
AI 생성 시연용 사진. 편집 화면과 출력 이미지는 실제 포토샷에서 만들었습니다.
출처에서 시작하다.
포인터 이동을 일정한 간격의 팁으로 바꾼다
brush-engine.ts는 포인터 경로를 따라 팁을 반복해서 찍습니다. 팁 간격은 max(0.5, size×spacing÷100)입니다. 이번 40px 브러시에서는 15%가 6px, 150%가 60px에 해당합니다. 화면 확대율과 원본 문서의 픽셀 간격은 구분해야 합니다.
이벤트가 들어올 때마다 무조건 한 점을 찍는 구조가 아닙니다. 앞 구간에서 다음 팁까지 남은 거리를 이어받고, 두 포인터 점 사이를 보간합니다. 포인터 이벤트 간격 때문에 선의 밀도가 달라지는 문제를 줄이기 위한 연결입니다.
선마다 설정과 난수 씨드를 보관한다
브러시는 팁 모양뿐 아니라 흐름·불투명도·간격·흩뿌리기·크기 변화 등 여러 값을 사용합니다. brush-dynamics.ts는 비트맵의 크기와 알파 배열을 검사합니다. 같은 화면에 보여도 잘못된 배열이나 범위 밖 설정을 그대로 받아서는 안 됩니다.
랜덤한 변화도 다시 그릴 수 있어야 합니다. 엔진은 씨드에서 난수열을 만들며, 저장된 두 선에도 각자의 씨드와 설정이 남았습니다. 이번 비교는 크기·각도 흔들림과 흩뿌리기를 0으로 두어 간격 변화에 집중했습니다.
export type BrushPoint=Point&{pressure?:number;tiltX?:number;tiltY?:number;twist?:number;wheel?:number};
export type BrushTip={width:number;height:number;alpha:number[]};
export type BrushSettings={symmetry?:BrushSymmetry;controls?:BrushControls;minimumDiameter?:number;minimumRoundness?:number;roundnessJitter?:number;countJitter?:number;scatterBothAxes?:boolean;purity?:number;textureMode?:typeof brushMaskModes[number];textureBrightness?:number;textureContrast?:number;textureInvert?:boolean;tipModel?:'round'|'bristle'|'erodible';bristleDensity?:number;bristleThickness?:number;bristleLength?:number;bristleStiffness?:number;erosion?:number;hardness:number;opacity:number;flow:number;spacing:number;roundness:number;angle:number;sizeJitter:number;angleJitter:number;scatter:number;count:number;pressureSize:boolean;pressureOpacity:boolean;flipX:boolean;flipY:boolean;textureDepth:number;textureScale:number;tip?:BrushTip;texture?:BrushTip;hueJitter?:number;saturationJitter?:number;brightnessJitter?:number;backgroundMix?:number;backgroundColor?:string;colorPerTip?:boolean;tiltAngle?:boolean;tiltRoundness?:boolean;twistAngle?:boolean;dual?:{mode?:typeof brushMaskModes[number];size:number;spacing:number;scatter:number;count:number;tip?:BrushTip}};
export const defaultBrush:BrushSettings={hardness:100,opacity:100,flow:100,spacing:15,roundness:100,angle:0,sizeJitter:0,angleJitter:0,scatter:0,count:1,pressureSize:false,pressureOpacity:false,flipX:false,flipY:false,textureDepth:0,textureScale:4};
export function validateBrush(b:BrushSettings){if(b.symmetry)validateSymmetry(b.symmetry);validateBrushControls(b);for(const [k,min,max] of [['hardness',0,100],['opacity',0,100],['flow',0,100],['spacing',1,300],['roundness',1,100],['angle',-360,360],['sizeJitter',0,100],['angleJitter',0,180],['scatter',0,500],['count',1,16],['textureDepth',0,100],['textureScale',1,64]] as const)if(!Number.isFinite(b[k])||b[k]<min||b[k]>max)throw Error('Invalid brush '+k);if(!Number.isInteger(b.count)||['pressureSize','pressureOpacity','flipX','flipY'].some(k=>typeof b[k as keyof BrushSettings]!=='boolean'))throw Error('Invalid brush dynamics.');for(const k of ['hueJitter','saturationJitter','brightnessJitter','backgroundMix'] as const)if(b[k]!==undefined&&(!Number.isFinite(b[k])||b[k]!<0||b[k]!>100))throw Error('Invalid brush color dynamics.');for(const k of ['colorPerTip','tiltAngle','tiltRoundness','twistAngle'] as const)if(b[k]!==undefined&&typeof b[k]!=='boolean')throw Error('Invalid brush pen dynamics.');if(b.backgroundColor!==undefined&&!/^#[0-9a-f]{6}$/i.test(b.backgroundColor))throw Error('Invalid brush background color.');if(b.tip)validateTip(b.tip);if(b.texture)validateTip(b.texture);if(b.dual){const d=b.dual;for(const [key,min,max] of [['size',5,200],['spacing',25,300],['scatter',0,300],['count',1,8]] as const)if(!Number.isFinite(d[key])||d[key]<min||d[key]>max)throw Error('Invalid dual brush.');if(!Number.isInteger(d.count))throw Error('Invalid dual brush count.');if(d.tip)validateTip(d.tip);}}계산의
안쪽으로.
ABR 가져오기에는 분명한 범위가 있다
brush-presets.ts는 ABR 주 버전 6·7·9·10과 부 버전 1·2를 검사하며 파일을 32MiB로 제한합니다. 구간 길이와 샘플 경계를 확인한 뒤 파서에 넘깁니다. 큰 팁은 최대 512px로 줄이고 경고를 모읍니다.
읽을 수 있는 설정을 내부 브러시 모델로 연결하는 과정이므로 모든 Adobe 브러시가 그대로 동작한다는 뜻은 아닙니다. 이 시연에서는 새 ABR을 가져오지 않았습니다. 현재 원고의 ABR 설명은 읽은 구현의 지원 범위입니다.
실제 화면에서 간격과 프리셋을 확인했다
2026년 10월 22일 Brush spacing study 레이어에 40px Hard Round로 위쪽은 Spacing 15%, 아래쪽은 150%로 수평선을 그렸습니다. Hardness·Opacity·Flow는 100, 압력에 따른 크기·불투명도는 꺼져 있습니다. 위는 이어지고 아래는 분리된 점으로 보였습니다.
150% 설정을 Journal25 Dots 40 150이라는 프리셋으로 저장했습니다. 새 편집 창에서 그 이름을 선택하자 Size 40과 Spacing 150이 돌아왔습니다. 문서의 선에 저장된 설정과 브라우저의 프리셋 라이브러리는 서로 다른 보관 대상입니다.
코드를 읽다.
export function paintBrush(ctx:CanvasRenderingContext2D,points:BrushPoint[],size:number,color:string,b:BrushSettings,seed=1,bounds?:{width:number;height:number}){if(!points.length)return;if(b.symmetry){const matrix=ctx.getTransform(),width=bounds?.width??ctx.canvas.width/Math.hypot(matrix.a,matrix.b),height=bounds?.height??ctx.canvas.height/Math.hypot(matrix.c,matrix.d),plain={...b,symmetry:undefined};for(const transform of symmetryTransforms(b.symmetry,width,height)){ctx.save();try{ctx.transform(...transform);paintBrush(ctx,points,size,color,plain,seed,bounds);}finally{ctx.restore();}}return;}const modelTip=proceduralBrushTip(b);if(modelTip!==b.tip)b={...b,tip:modelTip};const baseTip=makeTip(b,color);let randomSeed=seed||1;const random=()=>{randomSeed^=randomSeed<<13;randomSeed^=randomSeed>>>17;randomSeed^=randomSeed<<5;return (randomSeed>>>0)/4294967296;},spacing=Math.max(.5,size*b.spacing/100);let stamps=0,dabs=0,direction=0;const initialDirection=points.length>1?Math.atan2(points[1].y-points[0].y,points[1].x-points[0].x)*180/Math.PI:0,initialRotation=points[0].twist??0;const colors=!!(b.purity||b.hueJitter||b.saturationJitter||b.brightnessJitter||b.backgroundMix),strokeColor=colors&&b.colorPerTip===false?dynamicColor(color,b,random):color;
const dab=(p:BrushPoint)=>{const count=b.countJitter?Math.max(1,Math.round(b.count*(1-random()*b.countJitter/100))):b.count,control=(key:keyof BrushControls)=>brushControl(b.controls?.[key],p,dabs,direction,initialDirection,initialRotation);for(let i=0;i<count;i++){if(++stamps>100000)throw Error('Brush stroke is too long. Use a shorter stroke or increase spacing.');const tip=colors?makeTip(b,b.colorPerTip===false?strokeColor:dynamicColor(color,b,random)):baseTip;const pressure=Math.max(.01,Math.min(1,p.pressure??1)),radius=size*Math.max((b.minimumDiameter??0)/100,(b.pressureSize?pressure:1)*(1-random()*b.sizeJitter/100)*control('size'))*(b.tipModel==='erodible'?1+Math.min(1,dabs*(b.erosion??25)/2000)*.5:1),angle=(b.angle+(b.controls?.angle&&b.controls.angle.control!=='off'?control('angle')*360:0)+(random()*2-1)*b.angleJitter+(b.twistAngle?p.twist??0:0)+(b.tiltAngle&&((p.tiltX??0)||(p.tiltY??0))?Math.atan2(p.tiltY??0,p.tiltX??0)*180/Math.PI:0))*Math.PI/180,dx=(random()*2-1)*size*b.scatter/100,dy=(b.scatterBothAxes===false?0:random()*2-1)*size*b.scatter/100;ctx.save();ctx.globalAlpha=b.flow/100*(b.pressureOpacity?pressure:1)*control('flow')*control('opacity');ctx.translate(p.x+dx,p.y+dy);ctx.rotate(angle);ctx.scale(b.flipX?-1:1,b.flipY?-1:1);const width=radius*tip.width/Math.max(tip.width,tip.height),height=radius*tip.height/Math.max(tip.width,tip.height)*Math.max((b.minimumRoundness??1)/100,b.roundness/100*control('roundness')*(b.roundnessJitter?1-random()*b.roundnessJitter/100:1))*(b.tipModel==='bristle'?Math.max(.2,1-Math.hypot(p.tiltX??0,p.tiltY??0)/90*(1-(b.bristleStiffness??50)/100)):1)*(b.tiltRoundness?Math.max(.1,Math.cos(Math.min(89,Math.hypot(p.tiltX??0,p.tiltY??0))*Math.PI/180)):1);ctx.drawImage(tip,-width/2,-height/2,width,height);ctx.restore();}dabs++;};
dab(points[0]);let distanceToNext=spacing;for(let i=1;i<points.length;i++){const a=points[i-1],z=points[i],length=Math.hypot(z.x-a.x,z.y-a.y);if(!length)continue;direction=Math.atan2(z.y-a.y,z.x-a.x)*180/Math.PI;let d=distanceToNext;for(;d<=length;d+=spacing){const t=d/length;dab({x:a.x+(z.x-a.x)*t,y:a.y+(z.y-a.y)*t,pressure:(a.pressure??1)+((z.pressure??1)-(a.pressure??1))*t,tiltX:(a.tiltX??0)+((z.tiltX??0)-(a.tiltX??0))*t,tiltY:(a.tiltY??0)+((z.tiltY??0)-(a.tiltY??0))*t,wheel:(a.wheel??1)+((z.wheel??1)-(a.wheel??1))*t,twist:(a.twist??0)+((((z.twist??0)-(a.twist??0)+540)%360)-180)*t});}distanceToNext=d-length;}export async function importAbr(bytes:Uint8Array):Promise<{presets:BrushPreset[];warnings:string[]}>{if(bytes.byteLength>32*1024*1024||bytes.byteLength<4)throw Error('ABR files must be 32 MB or smaller.');const view=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength),version=view.getUint16(0),minor=view.getUint16(2);if(![6,7,9,10].includes(version)||![1,2].includes(minor))throw Error('Import an ABR version 6, 7, 9 or 10 brush library.');let total=0;for(let offset=4;offset<bytes.length;){if(offset+12>bytes.length)throw Error('Truncated ABR section.');const type=String.fromCharCode(...bytes.subarray(offset+4,offset+8)),size=view.getUint32(offset+8),end=offset+12+size;if(end>bytes.length)throw Error('Truncated ABR section.');if(type==='samp'){for(let p=offset+12;p<end;){if(p+5>end)throw Error('Truncated ABR sample.');const length=view.getUint32(p),start=p+4,bounds=start+1+bytes[start]+(minor===1?10:264);if(bounds+19>start+length||start+length>end)throw Error('Invalid ABR sample.');const h=view.getInt32(bounds+8)-view.getInt32(bounds),w=view.getInt32(bounds+12)-view.getInt32(bounds+4);total+=w*h;if(w<1||h<1||w>8192||h>8192||total>32*1024*1024)throw Error('ABR sample images exceed the 32 MP import limit.');p=start+Math.ceil(length/4)*4;}}offset=end+(4-size%4)%4;}
const {readAbr}=await import('./vendor/psd-codec.mjs'),abr=readAbr(bytes),warnings:string[]=[],presets:BrushPreset[]=[],used=new Set<string>();const tip=(id:string):BrushTip|undefined=>{const s=abr.samples.find(s=>s.id===id);if(!s)return;used.add(id);const ratio=Math.min(1,512/Math.max(s.bounds.w,s.bounds.h)),width=Math.max(1,Math.round(s.bounds.w*ratio)),height=Math.max(1,Math.round(s.bounds.h*ratio));if(ratio<1)warnings.push('Large brush tips were resized to 512 pixels.');return {width,height,alpha:Array.from({length:width*height},(_,i)=>s.alpha[Math.min(s.bounds.h-1,Math.floor((Math.floor(i/width)+.5)/ratio))*s.bounds.w+Math.min(s.bounds.w-1,Math.floor((i%width+.5)/ratio))])};};
for(const b of abr.brushes.slice(0,256)){const shape=b.shape;if(!['computed','sampled','dynamic','tips'].includes(shape.type)){warnings.push(b.name+': unknown brush shape.');continue;}const sample=shape.type==='sampled'?tip(shape.sampledData):undefined;if(shape.type==='sampled'&&!sample){warnings.push(b.name+': missing sample.');continue;}const settings:BrushSettings={...defaultBrush,hardness:shape.type==='computed'?clamp(shape.hardness*100,0,100):100,spacing:clamp(shape.spacingOn?shape.spacing*100:10,1,300),roundness:clamp(('roundness'in shape?shape.roundness:1)*100,1,100),angle:clamp(shape.angle,-360,360),flipX:!!shape.flipX,flipY:!!shape.flipY,tip:sample,sizeJitter:clamp((b.shapeDynamics?.sizeDynamics.jitter??0)*100,0,100),angleJitter:clamp((b.shapeDynamics?.angleDynamics.jitter??0)*180,0,180),pressureSize:b.shapeDynamics?.sizeDynamics.control==='pen pressure',pressureOpacity:b.transfer?.opacityDynamics.control==='pen pressure',scatter:clamp((b.scatter?.scatterDynamics.jitter??0)*100,0,500),count:Math.round(clamp(b.scatter?.count??1,1,16)),flow:clamp((b.toolOptions?.flow??1)*100,0,100),opacity:clamp((b.toolOptions?.opacity??1)*100,0,100)};if(shape.type==='dynamic'){Object.assign(settings,{tipModel:'bristle',bristleDensity:clamp(shape.density*100,1,100),bristleThickness:clamp(shape.thickness*100,1,100),bristleLength:clamp(shape.length*100,1,100),bristleStiffness:clamp(shape.stiffness*100,1,100)});}if(shape.type==='tips'){settings.tipModel='erodible';settings.erosion=clamp((1-shape.tipsHardness)*100,0,100);const heights=shape.tipsErodibleTipHeightMap,n=shape.tipsGridSize??(heights?Math.sqrt(heights.length):0);if(heights&&Number.isInteger(n)&&n>0&&n<=512&&heights.length===n*n){const min=heights.reduce((a,b)=>Math.min(a,b),Infinity),max=heights.reduce((a,b)=>Math.max(a,b),-Infinity);settings.tip={width:n,height:n,alpha:heights.map(v=>max===min?255:(v-min)/(max-min)*255)};}}settings.minimumDiameter=clamp((b.shapeDynamics?.minimumDiameter??0)*100,0,100);settings.minimumRoundness=clamp((b.shapeDynamics?.minimumRoundness??.01)*100,1,100);settings.roundnessJitter=clamp((b.shapeDynamics?.roundnessDynamics.jitter??0)*100,0,100);settings.countJitter=clamp((b.scatter?.countDynamics.jitter??0)*100,0,100);settings.scatterBothAxes=b.scatter?.bothAxes??true;settings.controls={};for(const [key,d]of Object.entries({size:b.shapeDynamics?.sizeDynamics,angle:b.shapeDynamics?.angleDynamics,roundness:b.shapeDynamics?.roundnessDynamics,opacity:b.transfer?.opacityDynamics,flow:b.transfer?.flowDynamics}))if(d)settings.controls[key as keyof typeof settings.controls]={control:d.control,steps:Math.round(clamp(d.steps||25,1,10000)),minimum:clamp(d.minimum*100,0,100)};if(settings.controls.size?.control==='pen pressure')settings.pressureSize=false;if(settings.controls.opacity?.control==='pen pressure')settings.pressureOpacity=false;if(b.colorDynamics){settings.purity=clamp(b.colorDynamics.purity*100,-100,100);settings.hueJitter=clamp(b.colorDynamics.hue*100,0,100);settings.saturationJitter=clamp(b.colorDynamics.saturation*100,0,100);settings.brightnessJitter=clamp(b.colorDynamics.brightness*100,0,100);settings.backgroundMix=clamp(b.colorDynamics.foregroundBackground.jitter*100,0,100);settings.colorPerTip=!!b.colorDynamics.perTip;if(b.colorDynamics.foregroundBackground.control!=='off')warnings.push(b.name+': foreground/background control is imported as jitter.');}settings.tiltAngle=false;settings.twistAngle=false;settings.tiltRoundness=false;if(b.dualBrush){const d=b.dualBrush;if((['normal',...brushMaskModes] as string[]).includes(d.blendMode.replaceAll(' ','-'))&&['computed','sampled'].includes(d.shape.type)){const dualTip=d.shape.type==='sampled'?tip(d.shape.sampledData):undefined;if(d.shape.type!=='sampled'||dualTip){settings.dual={mode:(d.blendMode==='normal'?'multiply':d.blendMode.replaceAll(' ','-')) as typeof brushMaskModes[number],size:clamp(d.shape.size/Math.max(1,shape.size)*100,5,200),spacing:clamp(d.spacing*100,25,300),scatter:clamp((d.scatterDynamics.jitter??0)*100,0,300),count:Math.round(clamp(d.count,1,8)),tip:dualTip};warnings.push(b.name+': dual tip mask imported; Adobe placement dynamics may differ.');}}if(!settings.dual)warnings.push(b.name+': this dual-tip mode is unsupported.');}validateBrush(settings);presets.push({id:crypto.randomUUID(),name:b.name||'Imported brush',size:clamp(shape.size,1,300),settings});if(b.texture){const t=b.texture,pattern=abr.patterns.find(p=>p.id===t.id);if(pattern){const width=Math.min(512,pattern.bounds.w),height=Math.min(512,pattern.bounds.h);settings.texture={width,height,alpha:Array.from({length:width*height},(_,i)=>{const x=Math.min(pattern.bounds.w-1,Math.floor((i%width+.5)/width*pattern.bounds.w)),y=Math.min(pattern.bounds.h-1,Math.floor((Math.floor(i/width)+.5)/height*pattern.bounds.h)),k=(y*pattern.bounds.w+x)*4;return Math.round((pattern.data[k]*.2126+pattern.data[k+1]*.7152+pattern.data[k+2]*.0722)*pattern.data[k+3]/255);})};settings.textureDepth=clamp(t.depth*100,0,100);settings.textureScale=clamp(t.scale,1,64);settings.textureInvert=t.invert;settings.textureBrightness=clamp(t.brightness,-100,100);settings.textureContrast=clamp(t.contrast,-100,100);const mode=t.blendMode.replaceAll(' ','-');if((brushMaskModes as readonly string[]).includes(mode))settings.textureMode=mode as typeof brushMaskModes[number];}else warnings.push(b.name+': embedded texture resource is missing.');validateBrush(settings);}}
if(!abr.brushes.length)for(const s of abr.samples.slice(0,256))presets.push({id:crypto.randomUUID(),name:'Sample '+(presets.length+1),size:clamp(Math.max(s.bounds.w,s.bounds.h),1,300),settings:{...defaultBrush,tip:tip(s.id)}});if(!presets.length)throw Error('No supported sampled or round brushes found.');return {presets,warnings:[...new Set(warnings)]};}function database(){return new Promise<IDBDatabase>((resolve,reject)=>{const q=indexedDB.open('photoshot-brush-library',1);q.onupgradeneeded=()=>q.result.createObjectStore('presets');q.onsuccess=()=>resolve(q.result);q.onerror=()=>reject(q.error);});}
export async function loadBrushPresets(){const db=await database();try{return await new Promise<BrushPreset[]>((resolve,reject)=>{const q=db.transaction('presets').objectStore('presets').get('library');q.onsuccess=()=>{try{const list=q.result??[];if(!Array.isArray(list)||list.length>256)throw Error('Invalid brush library.');for(const p of list){if(typeof p.name!=='string'||p.name.length>200||!Number.isFinite(p.size)||p.size<1||p.size>300)throw Error('Invalid brush preset.');validateBrush(p.settings);}resolve(list);}catch(e){reject(e);}};q.onerror=()=>reject(q.error);});}finally{db.close();}}
export async function saveBrushPresets(presets:BrushPreset[]){if(presets.length>256)throw Error('Brush library holds up to 256 custom presets.');const db=await database();try{await new Promise<void>((resolve,reject)=>{const tx=db.transaction('presets','readwrite');tx.objectStore('presets').put(presets,'library');tx.oncomplete=()=>resolve();tx.onerror=()=>reject(tx.error);tx.onabort=()=>reject(tx.error);});}finally{db.close();}}편집 화면에서.

실제 펜과 자동 포인터 검사를 혼동하지 않는다
저장한 프로젝트에는 두 선이 있고 각각 간격 15와 150, 크기 40이 들어 있습니다. 이번 조작은 자동화된 포인터 드래그이며 실제 펜의 필압·기울기·회전을 측정한 시험은 아닙니다. 프리셋은 같은 브라우저 저장소의 새 창에서 확인했습니다.
기존 brush-advanced-ui 보고서도 Chrome 펜 이벤트 경로를 검사했으며 하드웨어별 드라이버는 시험하지 않았다고 명시합니다. 렌더 보고서의 결정적 재그리기와 고급 설정 보존 역시 지정된 과거 사례입니다. 인물은 AI 생성이고 작업 화면은 실제 캡처입니다. 26편은 복제·복구·패치 도구로 이어집니다.
원본과 브러시 간격.


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