
Building a free Photoshop alternative
Keep the source. Develop again.
A corrected PNG and camera sensor RAW are different inputs. The new portrait receives Camera Raw Filter; separate Bayer DNG checks cover embedded originals, 16-bit opening and redevelopment. We also compare a fresh real Sony ARW and DCP integration result.
AI-generated demonstration image; not an actual celebrity photograph or official collaboration. Editing screens and results were made in Photoshot.
From the source.
Separate adjustment filters from sensor decoding
The model image here is a generated PNG. In public Photoshot we set Camera Raw Filter Exposure to 0.35 and applied it. We checked raw.values.exposure, preservation of the original src and exact project state after reopening.
This applies editable adjustments to an existing RGB picture. It does not create sensor data inside a PNG. File → Open Camera RAW is a separate path that decodes sensor bytes through a LibRaw worker.
Decode in a worker and match the camera
camera-raw-import validates the extension, rejects empty files and checks a 100,000,000-byte limit before transferring the file buffer to a worker. Cancellation and a 120-second timeout terminate the worker. Half Size, Camera WB and Auto Bright are development options at this stage.
Selecting a DCP first validates the profile and compares its camera with the actual RAW make and model. A mismatched camera profile is not silently applied. Profile color processing and sensor decoding are separate stages, which also makes failures easier to locate.
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();});
}Inside the
calculation.
Open a native 16-bit document explicitly
The fresh UI check used a 96×64 synthetic Bayer DNG. Open 16-bit Image produced a document whose precision depth was verified as 16. This is a separate choice from the default Open Image path with 8-bit sRGB editing. A displayed preview alone does not establish internal precision.
Native 16-bit documents retain developed channel values in dedicated precision data and support up to 16 MP, with Half Size suggested for larger inputs. Merely exporting an 8-bit result to a 16-bit file does not recover lost sensor information.
Keep sensor bytes and a hash inside the project
Keep RAW Original stores the name, byte count, Base64 data, SHA-256 and development options. Its 32,000,000-byte embedding limit differs from the 100 MB import limit. Length and hash are checked again before redevelopment to detect damaged originals.
We saved and reopened the project, then used Re-develop Embedded RAW. Half Size produced a 48×32 document while preserving the embedded sensor bytes and source hash. The small sensor fixture has its own workspace image below; it is not presented as the RAW original of the portrait PNG.

Read the code.
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)}]};}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'});}// 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,'');In the editor.

Give real-camera and small-fixture checks different roles
The separately rerun integration check used a real Sony ARW and a matching installed DCP. Output dimensions were 3120×2084; the check counted 64,794 changed samples and 106 clipped samples, with the source unchanged. These results do not establish support or color accuracy for every camera.
The small DNG makes save/reopen, Half Size and source-integrity checks repeatable. The real ARW checks sensor decoding and profile integration. Keeping both records distinct makes the claims testable again. Episode 37 continues with Camera Raw adjustments and XMP presets.
Source and result.


AI-generated demonstration image; not an actual celebrity photograph or official collaboration. Editing screens and results were made in Photoshot.