原本を残して再現像する / original
DEVELOPMENT JOURNAL / 36
Photoshot
CODE × UI
無料Photoshop代替の開発記

原本を残して再現像する

補正したPNGとカメラのセンサーRAWは異なる入力です。新しい人物PNGにはCamera Raw Filterを適用し、原本保存、16ビット読込、再現像は別のBayer DNGで検査しました。実際のSony ARWとDCPの再検査結果も照合します。

PHOTOSHOT DEVELOPMENT JOURNAL / 36

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

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

ソースから始める。

補正フィルターとセンサーデコードを分ける

今回のモデル画像は生成PNGです。公開版Camera Raw FilterでExposureを0.35にして適用しました。raw.values.exposureの値、原本srcの維持、プロジェクト再読込後の状態一致を確認しました。

これは既存のRGB画像に編集可能な補正を加える検査です。PNGの中にセンサー情報が生まれるわけではありません。FileのOpen Camera RAWは、LibRawワーカーでセンサーバイトをデコードする別の経路です。

ワーカーで開きカメラを照合する

camera-raw-importは拡張子、空ファイル、100,000,000バイト上限を確認し、バッファをワーカーに渡します。取消信号と120秒制限も処理してワーカーを終了します。Half Size、Camera WB、Auto Brightはこの段階の現像条件です。

DCP選択時はプロファイルを検証し、実際のRAWのメーカーと機種に一致するかを確認します。異なるカメラ用のプロファイルを黙って適用しません。色処理とセンサーデコードは別工程なので失敗原因も分けて確認できます。

app/studio/camera-raw-import.ts · SHA-256 b7ca37aada2c · L10–14Photoshotの実装コード ↗
export async function decodeCameraRaw(file:File,options:RawDecodeOptions,signal:AbortSignal,onProgress=(s:string)=>{}):Promise<DecodedCameraRaw>{
 if(options.profile)validateDcp(options.profile);if(options.profileTemperature!==undefined&&(!Number.isFinite(options.profileTemperature)||options.profileTemperature<2000||options.profileTemperature>50000))throw Error('Profile illumination must be between 2000 and 50000 K.');if(!isCameraRaw(file.name)||!file.size||file.size>100_000_000)throw Error('Choose a camera RAW file up to 100 MB.');if(signal.aborted)throw new DOMException('RAW import cancelled.','AbortError');const buffer=await file.arrayBuffer();if(signal.aborted)throw new DOMException('RAW import cancelled.','AbortError');
 const base=((import.meta as ImportMeta & {env?:{BASE_URL?:string}}).env?.BASE_URL||'/photoshot/editor/')+'raw-decoder/';
 return new Promise((resolve,reject)=>{const worker=new Worker(base+'photoshot-worker.js',{type:'module'});let finished=false;const finish=(error?:Error,result?:DecodedCameraRaw)=>{if(finished)return;finished=true;worker.terminate();clearTimeout(timeout);signal.removeEventListener('abort',abort);if(error)reject(error);else resolve(result!);},abort=()=>finish(new DOMException('RAW import cancelled.','AbortError')),timeout=setTimeout(()=>finish(Error('RAW decode timed out. Try Half Size or a different file.')),120000);signal.addEventListener('abort',abort,{once:true});worker.onmessage=async({data})=>{if(data.progress)onProgress(data.progress);else if(data.error)finish(Error(data.error));else if(data.result){try{const raw=data.result as DecodedCameraRaw;if(options.profile){if(!cameraFits(options.profile.camera,raw.metadata.make,raw.metadata.model))throw Error('This DCP belongs to '+options.profile.camera+', not '+raw.metadata.make+' '+raw.metadata.model+'.');onProgress('Applying camera profile…');raw.bytes=await applyDcp(raw.bytes,options.profile,options.profileTemperature??6504,signal);raw.metadata.cameraProfile=options.profile.name;}if(signal.aborted)throw new DOMException('RAW import cancelled.','AbortError');finish(undefined,raw);}catch(e){finish(e as Error);}}};worker.onerror=()=>finish(Error('RAW decoder stopped. Try a smaller file or Half Size.'));worker.onmessageerror=()=>finish(Error('Invalid RAW decoder response.'));worker.postMessage({buffer,options:{half:options.half,cameraWB:options.cameraWB,autoBright:options.autoBright,cameraProfile:!!options.profile}},[buffer]);if(signal.aborted)abort();});
}
Photoshot無料Photoshop代替の開発記 / 36IMPLEMENTATION

計算の
内側へ。

16ビット読込は別の文書経路

新しいUI検査には96×64の合成Bayer DNGを使いました。Open 16-bit Imageで開き、文書の精度が16ビットであることを確認しました。既定のOpen Imageによる8ビットsRGB編集とは別の選択です。画面のプレビューだけで内部精度を判定していません。

ネイティブ16ビット文書は現像したチャンネル値を専用精度データに保持し、16MPまで対応します。大きい入力にはHalf Sizeを案内します。8ビット結果を16ビット形式に出力するだけでは、失われたセンサー情報は戻りません。

センサーバイトとハッシュを保存する

Keep RAW Originalは名前、バイト数、Base64、SHA-256、現像設定を保持します。内蔵保存上限は32,000,000バイトで、RAW読込の100MB上限とは異なります。再現像前に長さとハッシュを再確認して原本の破損を検出します。

保存プロジェクトを開き直してRe-develop Embedded RAWを実行しました。Half Size後は48×32になり、センサーバイトと原本ハッシュは維持されました。この小さな検査入力の作業画面を別に掲載しています。人物PNGのRAW原本としては扱っていません。

Synthetic Bayer DNG redevelopment, not the portrait
別のセンサー検査:内蔵した合成Bayer DNGをHalf Sizeで再現像する実画面。人物PNGのRAW原本ではありません。
PHOTOSHOT / SOURCE NOTES36

コードを読む。

app/studio/camera-raw-import.ts · SHA-256 b7ca37aada2c · L17–19Photoshotの実装コード ↗
export function cameraRawRGBA16(raw:DecodedCameraRaw){const out=new Uint16Array(raw.width*raw.height*4),view=new DataView(raw.bytes.buffer,raw.bytes.byteOffset,raw.bytes.byteLength);for(let i=0;i<raw.width*raw.height;i++){for(let k=0;k<3;k++)out[i*4+k]=view.getUint16((i*3+k)*2,true);out[i*4+3]=65535;}return out;}

export function cameraRawPrecisionDocument(raw:DecodedCameraRaw,name:string):StudioDoc{if(raw.width*raw.height>16_000_000)throw Error('Native 16-bit documents support up to 16 MP. Develop at Half Size.');const doc=cameraRawDocument(raw,name),data=new Float32Array(raw.width*raw.height*4),view=new DataView(raw.bytes.buffer,raw.bytes.byteOffset,raw.bytes.byteLength);for(let i=0;i<raw.width*raw.height;i++){for(let c=0;c<3;c++)data[i*4+c]=view.getUint16((i*3+c)*2,true)/65535;data[i*4+3]=1;}const layer=doc.layers[0];return {...doc,precision:{space:'RGB',depth:16},sourceFile:{...doc.sourceFile!,warnings:['LibRaw developed RGB 16-bit sensor values are retained for native precision editing. Some raster filters require explicit RGB 8-bit conversion.']},layers:[{...layer,precision:packPrecision({width:raw.width,height:raw.height,space:'RGB',depth:16,data}),precisionSource:precisionSource(layer.src)}]};}
app/studio/raw-source.ts · SHA-256 09aa49cfedea · L3–8Photoshotの実装コード ↗
export type EmbeddedRaw={version:1;name:string;bytes:number;base64:string;sha256:string;options:RawDecodeOptions};
export function validateEmbeddedRaw(value:unknown):asserts value is EmbeddedRaw{const r=value as EmbeddedRaw;if(!r||r.version!==1||typeof r.name!=='string'||r.name.length>255||!Number.isInteger(r.bytes)||r.bytes<1||r.bytes>32_000_000||typeof r.base64!=='string'||r.base64.length!==Math.ceil(r.bytes/3)*4||!/^[A-Za-z0-9+/]*={0,2}$/.test(r.base64)||!/^[0-9a-f]{64}$/.test(r.sha256)||!r.options||['half','cameraWB','autoBright'].some(k=>typeof r.options[k as keyof RawDecodeOptions]!=='boolean'))throw Error('Invalid embedded RAW source.');if(r.options.profile)validateDcp(r.options.profile);if(r.options.profileTemperature!==undefined&&(!Number.isFinite(r.options.profileTemperature)||r.options.profileTemperature<2000||r.options.profileTemperature>50000))throw Error('Invalid RAW profile illumination.');}
const digest=async(bytes:Uint8Array)=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',bytes as BufferSource)),v=>v.toString(16).padStart(2,'0')).join('');
export async function embedRawSource(file:File,options:RawDecodeOptions):Promise<EmbeddedRaw>{if(file.size<1||file.size>32_000_000)throw Error('Embedded RAW supports original files up to 32 MB. Uncheck Keep RAW Original for larger files.');const bytes=new Uint8Array(await file.arrayBuffer());let binary='';for(let i=0;i<bytes.length;i+=8192)binary+=String.fromCharCode(...bytes.subarray(i,i+8192));const result:EmbeddedRaw={version:1,name:file.name,bytes:bytes.length,base64:btoa(binary),sha256:await digest(bytes),options:{...options}};validateEmbeddedRaw(result);return result;}
export async function embeddedRawFile(source:EmbeddedRaw){validateEmbeddedRaw(source);const binary=atob(source.base64),bytes=Uint8Array.from(binary,c=>c.charCodeAt(0));if(bytes.length!==source.bytes||await digest(bytes)!==source.sha256)throw Error('Embedded RAW checksum mismatch. The original is damaged.');return new File([bytes],source.name,{type:'application/octet-stream'});}
app/studio/raw-dcp.ts · SHA-256 e4817a39d070 · L1–7Photoshotの実装コード ↗
// DCP tag interpretation adapted from RAWmakase (MIT); vendor/rawmakase.LICENSE.
import {defaultDngTone} from './raw-profile-tone.ts';
import {type RGB,type HsvTable,validateHsv,validateCurve,mul,XYZ_PRO,PRO_RGB,applyHsv,tone,encode,clamp} from './raw-profile-math.ts';
export type DcpProfile={version:1;name:string;camera:string;copyright:string;forward1:number[];forward2:number[];color1?:number[];color2?:number[];kelvin1:number;kelvin2:number;hue1?:HsvTable;hue2?:HsvTable;look?:HsvTable;curve:number[];exposure:number;blackRender:number};
export function validateDcp(p:DcpProfile){if(!p||p.version!==1||[p.name,p.camera,p.copyright].some(v=>typeof v!=='string'||v.length>4096)||!p.name||!p.camera||[p.kelvin1,p.kelvin2].some(v=>!Number.isFinite(v)||v<1500||v>50000)||!Number.isFinite(p.exposure)||Math.abs(p.exposure)>10||![0,1].includes(p.blackRender))throw Error('Invalid DCP profile.');for(const m of [p.forward1,p.forward2,p.color1,p.color2])if(m!==undefined&&(!Array.isArray(m)||m.length!==9||m.some(x=>!Number.isFinite(x)||Math.abs(x)>100)))throw Error('Invalid DCP matrix.');if(!p.forward1||!p.forward2)throw Error('DCP forward matrices required.');validateCurve(p.curve);for(const t of [p.hue1,p.hue2,p.look])if(t)validateHsv(t);if(p.hue2&&(!p.hue1||p.hue1.srgb!==p.hue2.srgb||String(p.hue1.dims)!==String(p.hue2.dims)))throw Error('DCP illuminant tables do not match.');return p;}
export function readDcp(b:Uint8Array):DcpProfile{if(b.length<8||b.length>16_000_000)throw Error('DCP files must be below 16 MB.');const v=new DataView(b.buffer,b.byteOffset,b.byteLength),le=b[0]===73&&b[1]===73;if(!le&&!(b[0]===77&&b[1]===77))throw Error('Invalid DCP byte order.');const bounds=(p:number,n:number)=>{if(!Number.isSafeInteger(p)||p<0||p+n>b.length)throw Error('DCP data is outside the file.');return p;},u16=(p:number)=>v.getUint16(bounds(p,2),le),u32=(p:number)=>v.getUint32(bounds(p,4),le);if(![42,0x4352].includes(u16(2)))throw Error('Invalid DCP header.');const off=u32(4),count=u16(off),tags=new Map<number,{kind:number;count:number;offset:number}>();if(count>512)throw Error('Too many DCP tags.');bounds(off+2,count*12+4);for(let i=0;i<count;i++){const p=off+2+i*12,id=u16(p),kind=u16(p+2),n=u32(p+4),size=({1:1,2:1,7:1,3:2,4:4,9:4,11:4,5:8,10:8,12:8} as Record<number,number>)[kind];if(!size||tags.has(id))throw Error('Invalid or duplicate DCP tag.');const offset=n*size<=4?p+8:u32(p+8);bounds(offset,n*size);tags.set(id,{kind,count:n,offset});}const nums=(id:number)=>{const t=tags.get(id);if(!t)throw Error('Missing DCP tag '+id);if(t.count>3_000_000)throw Error('DCP table is too large.');const out:number[]=[];for(let i=0;i<t.count;i++){const p=t.offset,n=t.kind===3?u16(p+i*2):t.kind===4?u32(p+i*4):t.kind===11?v.getFloat32(p+i*4,le):t.kind===10?v.getInt32(p+i*8,le)/v.getInt32(p+i*8+4,le):t.kind===5?u32(p+i*8)/u32(p+i*8+4):NaN;if(!Number.isFinite(n))throw Error('Invalid DCP numeric tag '+id);out.push(n);}return out;},scalar=(id:number,fallback:number)=>{if(!tags.has(id))return fallback;const a=nums(id);if(a.length!==1)throw Error('Invalid DCP scalar.');return a[0];},text=(id:number)=>{const t=tags.get(id);return t?new TextDecoder().decode(b.subarray(t.offset,t.offset+t.count)).replace(/\0+$/,''):'';},kelvin=(id:number,fallback=6504)=>{if(!tags.has(id))return fallback;const n=({17:2856,21:6504,23:5003,20:5503,22:7504,1:5500,4:5500,9:5500,24:3200} as Record<number,number>)[scalar(id,21)];if(!n)throw Error('Unsupported DCP calibration light.');return n;},table=(dims:number,data:number,enc:number):HsvTable|undefined=>{if(!tags.has(data))return;const d=nums(dims),e=scalar(enc,0);if(d.length!==3||![0,1].includes(e))throw Error('Invalid DCP table encoding.');return {dims:d as RGB,data:nums(data),srgb:e===1};};if([52531,52537,52538,52551].some(k=>tags.has(k)))throw Error('HDR and triple-illuminant DCP profiles are not supported.');if(scalar(50941,0)===2)throw Error('This DCP forbids embedding; choose an embeddable profile.');if(!tags.has(50964))throw Error('This DCP has no forward matrix.');const f=nums(50964),k=kelvin(50778),p:DcpProfile={version:1,name:text(50936),camera:text(50708),copyright:text(50942),forward1:f,forward2:tags.has(50965)?nums(50965):f.slice(),color1:tags.has(50721)?nums(50721):undefined,color2:tags.has(50722)?nums(50722):undefined,kelvin1:k,kelvin2:kelvin(50779,k),hue1:table(50937,50938,51107),hue2:table(50937,50939,51107),look:table(50981,50982,51108),curve:tags.has(50940)?nums(50940):defaultDngTone.flatMap((y,i)=>[i/(defaultDngTone.length-1),y]),exposure:scalar(51109,0),blackRender:scalar(51110,0)===1?1:0};return validateDcp(p);}
export function cameraFits(camera:string,make:string,model:string){const norm=(s:string)=>s.toLowerCase().replace(/[\s_\-]/g,'');
Photoshot無料Photoshop代替の開発記 / 36IN PRACTICE

エディターで見る。

原本を残して再現像する / workspace
人物PNGの補正フィルターを設定した公開版画面。センサーRAW検査は下に別掲しています。

実カメラと小さな検査入力の役割を分ける

別途再実行した統合検査は実際のSony ARWと一致するインストール済みDCPを使いました。結果は3120×2084、変更サンプル64,794、検査で数えたクリップサンプル106で、原本不変も確認しました。すべてのカメラの対応や色精度を保証する結果ではありません。

小さなDNGは保存、再読込、Half Size、原本の完全性の反復確認に向き、実ARWはセンサーデコードとプロファイル連携を確かめます。両者を分けて根拠を残しました。次回37ではCamera Raw調整とXMPプリセットを扱います。

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

原本と編集結果。

原本を残して再現像する / original
AI生成PNG原本。カメラRAWファイルではありません。
原本を残して再現像する / result
Camera Raw FilterでExposure 0.35を実際に適用した出力。

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

確認したコードと検査

コード・実行結果・検査日 ↗