線は小さな先端の連続 / original
DEVELOPMENT JOURNAL / 25
Photoshot
CODE × UI
無料Photoshop代替の開発記

線は小さな先端の連続

24回のAI原画像を開き直し、別のペイントレイヤーに二本の線を描きました。同じブラシサイズでも先端の間隔で、連続線と離れた点に変わります。

PHOTOSHOT DEVELOPMENT JOURNAL / 25

AI生成の動作例用写真。編集画面と出力画像は実際のPhotoshotで作成しました。

Photoshot無料Photoshop代替の開発記 / 25FOUNDATION

ソースから始める。

ポインター移動を一定間隔のスタンプへ

brush-engine.tsは経路に沿って先端を繰り返します。間隔はmax(0.5, size×spacing÷100)です。40pxなら15%は文書内6px、150%は60pxです。画面倍率と文書のピクセル距離を区別します。

イベントごとに一回押す処理ではありません。次のスタンプまでの残り距離を次区間へ引き継ぎ、二点間を補間します。イベント密度だけで線密度が変わる問題を減らすためです。

線ごとに設定とシードを残す

先端形状、流量、不透明度、間隔、散布、サイズ変化は別の設定です。brush-dynamics.tsはビットマップ寸法とアルファ配列を検査します。見た目がもっともらしくても不正データは受け取れません。

ランダム変化も再現できる必要があります。エンジンはシードから乱数列を作り、保存した二本にもそれぞれシードと設定があります。今回はサイズ・角度の揺れと散布を0にして間隔へ焦点を絞りました。

app/studio/brush-engine.ts · SHA-256 626aa55dbe83 · L5–9Photoshotの実装コード ↗
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);}}
Photoshot無料Photoshop代替の開発記 / 25IMPLEMENTATION

計算の
内側へ。

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で上15%、下150%の水平線を描きました。Hardness・Opacity・Flowは100、筆圧によるサイズと不透明度は無効です。上は連続し、下は点に分かれました。

150%設定をJournal25 Dots 40 150として保存しました。新しい編集ウィンドウで選ぶとSize 40とSpacing 150が戻りました。文書のストローク設定とブラウザーのプリセット集は別の保存対象です。

PHOTOSHOT / SOURCE NOTES25

コードを読む。

app/studio/brush-engine.ts · SHA-256 626aa55dbe83 · L13–15Photoshotの実装コード ↗
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;}
app/studio/brush-presets.ts · SHA-256 2fc100dfdf64 · L6–9Photoshotの実装コード ↗
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)]};}
app/studio/brush-presets.ts · SHA-256 2fc100dfdf64 · L10–12Photoshotの実装コード ↗
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();}}
Photoshot無料Photoshop代替の開発記 / 25IN PRACTICE

エディターで見る。

線は小さな先端の連続 / workspace
実際のPhotoshot作業画面。設定と写真を一緒に確認できます。

実機ペンと自動入力を区別する

保存プロジェクトにはサイズ40、間隔15と150の二本があります。今回は自動ポインタードラッグであり、実機ペンの筆圧・傾き・回転を測定した試験ではありません。同じブラウザー保存領域を使う新窓でプリセットを確認しました。

既存brush-advanced-ui報告もChromeのペンイベント経路の検査で、機器別ドライバーは未試験と明記しています。レンダー報告の再現性や高度設定保持も指定された過去の事例です。人物はAI生成、画面は実際のキャプチャーです。26回は複製・修復・パッチです。

Photoshot無料Photoshop代替の開発記 / 25BEFORE / AFTER

原画像とブラシ間隔。

線は小さな先端の連続 / original
01 / AI生成の実例原画像
線は小さな先端の連続 / result
02 / 40pxブラシ・上15%、下150%の間隔

AI生成の動作例用写真。編集画面と出力画像は実際のPhotoshotで作成しました。

確認したコードと検査

コード・検査記録・日付 ↗