
無料Photoshop代替の開発記
作業を再び開く
編集を後から続けるためのPhotoshot開発記です。18回と同じAI人物写真を使い、レイヤー名と不透明度を変更しました。プロジェクトファイルの再読み込みと、同じブラウザー内の自動復旧を分けて確認します。
AI生成の動作例用写真。編集画面と出力画像は実際のPhotoshotで作成しました。
ソースから始める。
画像出力とプロジェクト保存の違い
PNGは見えている合成結果を一枚の画像として渡せます。後からレイヤー名・不透明度・マスクを編集するには文書構造が必要です。運用編集ソフトのsaveProjectは現在の文書をJSONにし、.layerstudioとしてダウンロードします。
対象は現在の文書であり、開いている全タブやUndo履歴全体ではありません。PSDとの交換は別経路です。.layerstudioで保持できる項目がPSDでもすべて同じとは推定しません。
自動保存は作業空間と直前の一つを残す
workspace-store.tsのSavedWorkspaceはactiveId、文書タブ、savedAtを持ちます。IndexedDBのdocumentsにworkspaceを書き、既存値があれば先にworkspace-recoveryへ移します。直前の作業空間一つであり、永続的な版管理ではありません。
Promiseキューで書き込みを順序付けます。読み込み時はバージョン、タブ配列、最大20件、アクティブタブを確認し、各文書にvalidateDocを適用します。名前付き履歴スナップショットは文書あたり最大5件で、名前・時刻・文書も検証します。
export type SmartParent={tabId:string;linkedId:string;sourceKey?:string;sourceHash?:string};
export type HistorySnapshot={id:string;name:string;createdAt:number;doc:StudioDoc};
export type SavedTab={id:string;doc:StudioDoc;smartParent?:SmartParent;artboardParent?:ArtboardParent;snapshots?:HistorySnapshot[]};
export type SavedWorkspace={version:1;activeId:string;tabs:SavedTab[];savedAt:number};計算の
内側へ。
復旧はUndo全履歴の復元ではない
editor.tsxは通常の作業空間読み込みが例外になった場合、復旧用のコピーを試します。セッション再構築時のundoとredoは空配列です。現在の文書を戻すことと過去の全操作を再生することは異なります。
画面は保存中とこの端末に保存済みを区別し、失敗時にはプロジェクト保存を案内します。自動保存は同じブラウザー内のデータです。別端末へ移す場合やブラウザーデータ削除後も残す場合は、ダウンロードしたファイルが必要です。
レイヤー名と87%が戻った
2026年10月22日、18回の1536×1024写真でレイヤー名をJournal 19 recovery check、不透明度を87%にしました。保存ファイルの名前とopacity 0.87を確認し、File → Open Projectで再読み込み。新しい文書タブでも同じ名前と87%が表示され、Undoは無効でした。
保存済み表示の後に別の編集画面を開くと、五つの文書タブ、アクティブ文書、同じレイヤー設定が復旧しました。Undoはここでも無効です。検査用の画面は閉じました。以下は色補正の比較ではなく、保存された文書状態の確認です。
コードを読む。
export function saveWorkspace(value:SavedWorkspace){const snapshot=value;const job=queue.catch(()=>{}).then(async()=>{const db=await database();try{await new Promise<void>((resolve,reject)=>{const t=db.transaction('documents','readwrite'),s=t.objectStore('documents'),r=s.get('workspace');r.onsuccess=()=>{try{if(r.result)s.put(r.result,'workspace-recovery');s.put(snapshot,'workspace');const current=snapshot.tabs.find(t=>t.id===snapshot.activeId);if(current)s.put(current.doc,'current');}catch(error){try{t.abort();}catch{/* Already aborted. */}reject(error);}};t.oncomplete=()=>resolve();t.onabort=()=>reject(t.error??Error('Saving was cancelled.'));t.onerror=()=>reject(t.error);});}finally{db.close();}});queue=job;return job;}
export async function loadWorkspace(recovery=false):Promise<SavedWorkspace|null>{const db=await database();try{const v=await new Promise<SavedWorkspace|undefined>((resolve,reject)=>{const r=db.transaction('documents').objectStore('documents').get(recovery?'workspace-recovery':'workspace');r.onsuccess=()=>resolve(r.result);r.onerror=()=>reject(r.error);});if(!v)return null;if(v.version!==1||!Array.isArray(v.tabs)||v.tabs.length>20||!v.tabs.some(t=>t.id===v.activeId))throw Error('Invalid recovery workspace.');for(const t of v.tabs){validateDoc(t.doc);if(t.snapshots){if(!Array.isArray(t.snapshots)||t.snapshots.length>5)throw Error('Invalid history snapshots.');for(const s of t.snapshots){if(typeof s.id!=='string'||typeof s.name!=='string'||s.name.length>80||!Number.isFinite(s.createdAt))throw Error('Invalid history snapshot.');validateDoc(s.doc);}}}return v;}finally{db.close();}} useEffect(()=>{let live=true;(async()=>{let saved:SavedWorkspace|null=null;try{saved=await loadWorkspace();}catch{saved=await loadWorkspace(true);}if(!live)return;if(saved){sessions.current=new Map(saved.tabs.map(t=>[t.id,{doc:t.doc,undo:[],redo:[],selected:t.doc.layers.at(-1)?.id??null,zoom:.65,smartParent:t.smartParent,artboardParent:t.artboardParent,snapshots:t.snapshots}]));const current=sessions.current.get(saved.activeId)!;tabIdRef.current=saved.activeId;setTabId(saved.activeId);setTabs(saved.tabs.map(t=>({id:t.id,name:t.doc.name})));setDoc(current.doc);docRef.current=current.doc;setSelected(current.selected);}else{const d=await localLoad();if(live&&d){setDoc(d);docRef.current=d;setSelected(d.layers.at(-1)?.id??null);}}if(live){setReady(true);setTimeout(()=>fit(),30);}})().catch(()=>{setReady(true);notify('Recovery is unavailable. Save a project file.');});return()=>{live=false;aiAbort.current?.abort();};},[]); function saveProject(){download(new Blob([JSON.stringify(docRef.current)],{type:'application/json'}),`${docRef.current.name}.layerstudio`);notify('Project saved.');}
async function openProject(file?:File){if(!file)return;try{if(file.size>100*1024*1024)throw Error('Project files must be 100 MB or smaller.');const next=validateDoc(JSON.parse(await file.text()));await Promise.all(next.layers.filter(l=>l.src).map(l=>loadImage(l.src!)));openDocument(next);}catch(e){notify((e as Error).message);}}エディターで見る。

アートボードと過去の検査を区別する
複数の文書タブと、一文書内の複数アートボードは別です。モデルにはartboardsとartboardWorkspaceがあり、画面から領域の作成・移動・サイズ変更を行います。復元範囲は文書データと保存経路によって決まります。
保存済みworkspace-structure-ui報告はアートボードのグループ、内容移動、複製Undo/Redo、復旧の通過を記録しています。ファイル日付は2026年10月12日で、今日再実行した報告ではありません。今日の直接確認はファイル再読み込みと別画面の自動復旧です。容量不足、強制終了の全タイミング、異なるブラウザー間の移動まで保証しません。20回ではPSD・PSBの保持範囲を確認します。
原画像と復元画面。


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