
無料Photoshop代替の開発記
構造も保存する
20回のAI人物文書にグループと白い境界線を追加しました。合成画像の背後に残すべき関係や設定を、Photoshotの画面と文書データで確かめます。
AI生成の動作例用写真。編集画面と出力画像は実際のPhotoshotで作成しました。
ソースから始める。
グループには文書内の関係がある
レイヤーの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報告で、指定されたRGB・CMYK・Lab・GrayのPSD/PSB計18例の通過記録を確認しました。今日の再実行や全写真での同一結果とは説明しません。
ロックと二つのコンプを画面で確認
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の内側境界線と二つのコンプを確認しました。今回の直接検査はPhotoshotプロジェクトの往復です。グループマスクや全効果のPSD/PSB往復ではありません。
既存precision-groups-adjustments報告は指定の高精度グループ・マスク・調整の事例を通過と記録していますが、過去の証拠として引用します。人物は20回と同じAI生成モデル、画面は実際のキャプチャーです。22回は選択範囲・マスク・チャンネルに進みます。
原画像とレイヤーコンプ。


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