
無料Photoshop代替の開発記
効果を重ね、範囲を残す
32回では原本を共有しました。今回はその上に効果と適用範囲を重ねます。新しい作例写真にぼかし、シャープ、部分マスクを設定し、公開版PhotoshotでPSDを保存して開き直しました。
AI生成の動作例用写真。編集画面と出力画像は実際のPhotoshotで作成しました。
ソースから始める。
画素の前に効果の一覧を持つ
通常のフィルターは処理後の画素を残します。スマートフィルターは種類、数値、描画方式、有効状態を原本と分けて記録し、合成時に処理します。前回のスマートオブジェクトが再計算の原本になります。
現在のfilterCapabilitiesには21種類があり、PSDのネイティブ名を持つものは19種類です。過去の数字を流用せずコードを確認しました。Twirlには現在対応名があり、RippleとShearにはありません。メニューにあることと他のアプリで編集可能に保存できることは別です。
ぼかしてからシャープにする
新しいAI作例PNGをスマートオブジェクトに変え、写真の一部を矩形で選択しました。Gaussian BlurのRadiusは8、Unsharp MaskのRadiusは2、Amountは120です。保存データではGaussianの後にUnsharpが並びます。
ダイアログは最近追加した効果を上に表示するため、画面の上下と適用順序には注意が必要です。今回は画面と保存配列を照合しました。この二つの効果の確認を、すべての組み合わせの保証には広げていません。
export const filterCapabilities={
gaussian:{name:'Gaussian Blur',native:'gaussian blur'},unsharp:{name:'Unsharp Mask',native:'unsharp mask'},highpass:{name:'High Pass',native:'high pass'},box:{name:'Box Blur',native:'box blur'},motion:{name:'Motion Blur',native:'motion blur'},median:{name:'Median',native:'median'},minimum:{name:'Minimum',native:'minimum'},maximum:{name:'Maximum',native:'maximum'},mosaic:{name:'Mosaic',native:'mosaic'},noise:{name:'Add Noise',native:'add noise'},average:{name:'Average',native:'average'},blur:{name:'Blur',native:'blur'},blurMore:{name:'Blur More',native:'blur more'},sharpen:{name:'Sharpen',native:'sharpen'},sharpenEdges:{name:'Sharpen Edges',native:'sharpen edges'},sharpenMore:{name:'Sharpen More',native:'sharpen more'},findEdges:{name:'Find Edges',native:'find edges'},solarize:{name:'Solarize',native:'solarize'},
twirl:{name:'Twirl',native:'twirl'},ripple:{name:'Ripple',native:null},shear:{name:'Shear',native:null},
} as const;計算の
内側へ。
選択範囲を効果のマスクに変える
Mask from Selectionで選択範囲をスマートフィルターマスクにし、Densityを75にしました。検査では選択配列に0と0より大きい値が両方あることも確認しました。全体選択を部分マスクの検査として扱わないためです。
保存された密度は0.75です。原本を消すのではなく、効果の寄与を調節します。合成処理はマスクで原本と効果結果を混ぜ、各フィルターの不透明度や描画方式も別の値として扱います。
PSDを開き直して設定を比較する
公開版でSave As PSDを実行し、保存したファイルを開き直しました。前後のsmartFiltersとsmartFilterMaskを深く比較し、両方の一致を確認しました。対象は今回の二つの効果、数値、部分マスクです。
別途再実行した描画検査はマスク、拡張効果、精度別キャッシュを確認します。PSDでは効果適用前の原本キャッシュと結果を分ける必要があります。処理済みの画素を原本にすると、他のアプリが効果を二重に適用する恐れがあるためです。
コードを読む。
export function validateSmartFilters(filters:SmartFilter[]){if(!Array.isArray(filters)||filters.length>16)throw Error('Use up to 16 Smart Filters.');const ids=new Set();for(const f of filters){if(!f||typeof f.id!=='string'||ids.has(f.id)||!Object.hasOwn(filterNames,f.type)||!Number.isFinite(f.radius)||f.radius<0||f.radius>50||!Number.isFinite(f.amount)||f.amount<0||f.amount>500||!Number.isFinite(f.threshold)||f.threshold<0||f.threshold>255||!Number.isFinite(f.opacity)||f.opacity<0||f.opacity>1||!Object.hasOwn(blendNames,f.blend)||typeof f.enabled!=='boolean')throw Error('Invalid Smart Filter.');if(f.angle!==undefined&&(!Number.isFinite(f.angle)||f.angle < -180||f.angle>180)||f.seed!==undefined&&(!Number.isInteger(f.seed)||f.seed<1||f.seed>2147483647)||f.distribution!==undefined&&!['uniform','gaussian'].includes(f.distribution)||f.monochromatic!==undefined&&typeof f.monochromatic!=='boolean')throw Error('Invalid Smart Filter options.');if(f.edge!==undefined&&!['wrap','clamp','transparent'].includes(f.edge)||f.type==='shear'&&(!Array.isArray(f.shear)||f.shear.length!==5||f.shear.some(n=>!Number.isFinite(n)||Math.abs(n)>100)))throw Error('Invalid geometric Smart Filter.');ids.add(f.id);}}export function nativeSmartFilters(filters:SmartFilter[],mask?:LayerMask){validateSmartFilters(filters);const unsupported=filters.find(f=>!filterCapabilities[f.type].native);if(unsupported)throw Error(filterNames[unsupported.type]+' Smart Filter is editable in Photoshot projects. Native PSD export for this operation is not supported yet; save a project or rasterize a duplicate.');return {enabled:true,validAtPosition:true,maskEnabled:!!mask?.enabled,maskLinked:mask?.linked!==false,maskExtendWithWhite:!mask,list:filters.map(f=>({type:filterCapabilities[f.type].native,name:filterNames[f.type],enabled:f.enabled,hasOptions:!simpleSmartTypes.includes(f.type as any),opacity:f.opacity,blendMode:f.blend.replaceAll('-',' '),foregroundColor:{r:0,g:0,b:0},backgroundColor:{r:255,g:255,b:255},filter:simpleSmartTypes.includes(f.type as any)?undefined:f.type==='twirl'?{angle:f.angle??0}:f.type==='motion'?{angle:f.angle??0,distance:{units:'Pixels',value:f.radius}}:f.type==='mosaic'?{cellSize:{units:'Pixels',value:Math.max(1,Math.round(f.radius))}}:f.type==='noise'?{amount:f.amount/100,distribution:f.distribution??'uniform',monochromatic:f.monochromatic??true,randomSeed:f.seed??1}:{radius:{units:'Pixels',value:f.radius},...(f.type==='unsharp'?{amount:f.amount/100,threshold:f.threshold}:{})}}))};}export async function filterMaskGuard(cache:any){if(!cache?.extra)return undefined;const m=cache.extra,w=m.right-m.left,h=m.bottom-m.top;if(w<=0||h<=0||w*h>64*1024*1024||cache.depth!==8)return {unsupported:true};const pixels=decodePlane(m.data,m.compressionMode,w,h,true);return {bounds:[m.left,m.top,m.right,m.bottom],hash:Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new Uint8Array(pixels)))).map(n=>n.toString(16).padStart(2,'0')).join('')};}
export function editableNativeFilters(doc:StudioDoc,layer:Layer):Layer{if(layer.smartFilters!==undefined){validateSmartFilters(layer.smartFilters);return layer;}const native=unpackMetadata(layer.psdData??{}).placedLayer?.filter,cache=filterCache(doc,layer);if(!native?.list?.length)return layer;const names=Object.fromEntries(Object.entries(filterCapabilities).filter(([,c])=>c.native).map(([id,c])=>[c.native,id])) as Record<string,SmartFilter['type']>;if(native.list.some((f:any)=>!names[f.type]))throw Error('This stack contains filters that cannot yet be edited. Its original pixels and settings are preserved.');if(!cache||![8,16,32].includes(cache.depth)||cache.depth!==8&&!doc.precision)throw Error('This file has no supported unfiltered pixel cache. Open and save it in Photoshop before editing its Smart Filters.');const w=cache.right-cache.left,h=cache.bottom-cache.top;if(w<=0||h<=0||w*h>64*1024*1024)throw Error('The filter cache exceeds the supported size.');const filters:SmartFilter[]=native.list.map((f:any)=>({id:crypto.randomUUID(),type:names[f.type],radius:f.filter?.radius?.value??f.filter?.distance?.value??f.filter?.cellSize?.value??0,amount:['unsharp mask','add noise'].includes(f.type)?(f.filter.amount??0)*100:100,...(['motion blur','twirl'].includes(f.type)?{angle:f.filter.angle}:{}),...(f.type==='add noise'?{seed:Math.max(1,(f.filter.randomSeed??1)&2147483647),distribution:f.filter.distribution,monochromatic:f.filter.monochromatic}:{}),threshold:f.filter?.threshold??0,opacity:f.opacity??1,blend:(f.blendMode??'normal').replaceAll(' ','-'),enabled:native.enabled!==false&&f.enabled!==false}));validateSmartFilters(filters);let smartFilterMask:Layer['smartFilterMask'];if(cache.extra){const m=cache.extra,mw=m.right-m.left,mh=m.bottom-m.top;if(mw<=0||mh<=0||mw*mh>64*1024*1024)throw Error('Invalid filter mask size.');const a=cache.depth===8?decodePlane(m.data,m.compressionMode,mw,mh,true):Uint8Array.from(decodePrecisionPlane(m.data,m.compressionMode,mw,mh,cache.depth,true),v=>Math.round(Math.max(0,Math.min(1,v))*255)),mc=surface(mw,mh),mg=mc.getContext('2d')!,im=mg.createImageData(mw,mh);for(let p=0;p<a.length;p++){im.data[p*4]=im.data[p*4+1]=im.data[p*4+2]=a[p];im.data[p*4+3]=255;}mg.putImageData(im,0,0);const result=surface(layer.width,layer.height),rg=result.getContext('2d')!;rg.fillStyle=native.maskExtendWithWhite?'#fff':'#000';rg.fillRect(0,0,result.width,result.height);rg.drawImage(mc,m.left-layer.x,m.top-layer.y);const values=rg.getImageData(0,0,result.width,result.height).data;smartFilterMask={width:layer.width,height:layer.height,enabled:native.maskEnabled!==false,inverted:false,linked:native.maskLinked!==false,strokes:[],raster:{width:result.width,height:result.height,alpha:Array.from({length:result.width*result.height},(_,i)=>values[i*4])}};}if(doc.precision){let r=precisionLayerPixels({left:cache.left,top:cache.top,right:cache.right,bottom:cache.bottom,rawData:{large:true,channels:cache.channels.map((c:any,i:number)=>c?{id:i===25?-1:i,data:c.data,compression:c.compressionMode}:null).filter(Boolean)}},doc.precision.space,cache.depth as PrecisionDepth,profileBytes(doc.precision.profile));if(!r)throw Error('Missing native filter cache pixels.');if(w!==Math.round(layer.width)||h!==Math.round(layer.height)||cache.left!==layer.x||cache.top!==layer.y)r=resamplePrecision(r,Math.round(layer.width),Math.round(layer.height),(x,y)=>({x:x+layer.x-cache.left,y:y+layer.y-cache.top}));return {...layer,precision:packPrecision(r),precisionSource:precisionSource(layer.src),smartFilters:filters,smartFilterMask};}const c=surface(w,h),g=c.getContext('2d')!,data=g.createImageData(w,h);for(const [i,index]of [0,1,2,25].entries()){const ch=cache.channels[index];if(!ch&&index!==25)throw Error('The unfiltered cache is incomplete.');const plane=ch?(cache.depth===8?decodePlane(ch.data,ch.compressionMode,w,h,true):Uint8Array.from(decodePrecisionPlane(ch.data,ch.compressionMode,w,h,cache.depth,true),v=>Math.round(Math.max(0,Math.min(1,v))*255))):new Uint8Array(w*h).fill(255);for(let p=0;p<w*h;p++)data.data[p*4+i]=plane[p];}g.putImageData(data,0,0);const local=surface(layer.width,layer.height);local.getContext('2d')!.drawImage(c,cache.left-layer.x,cache.top-layer.y);const src=local.toDataURL();return {...layer,src,smartFilters:filters,smartFilterMask};}エディターで見る。

保存形式の境界を明確にする
RippleやShearのようにネイティブ対応のない効果はPSD保存時に確認されます。非対応の効果を説明なく消して出力する方法では解決になりません。編集状態全体を残すためにはPhotoshotプロジェクト保存も使います。
今回新たに実施したのはPhotoshot UIの保存と再読込、コード検査です。この写真をPhotoshopで新たに開いたとは述べていません。次回は通常のフィルターとFilter Galleryの効果スタックを扱います。
原本と編集結果。


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