Separate inks, reopen the result / original
DEVELOPMENT JOURNAL / 31
Photoshot
CODE × UI
Building a free Photoshop alternative

Separate inks, reopen the result

A saved duotone PSD failed to reopen while we were making a black-and-brown portrait. On October 22, 2026, we fixed the import condition, verified the same file in the public editor and checked CMYK TIFF and separation output.

PHOTOSHOT DEVELOPMENT JOURNAL / 31

AI-generated study photograph. Editor captures and outputs show Photoshot in use.

PhotoshotDEVELOPMENT JOURNAL / 31FOUNDATION

From the source.

Set the two ink curves directly

We reused the AI-generated fictional adult portrait from Part 30. Duotone used Black and Warm Brown. Brown was #98552e, with 35% ink at 50% input and 65% at 100%; Black retained its default straight curve. After Preview and OK, image colors became densities of these two inks.

duotone.ts composites onto white paper, converts to grayscale and linearly interpolates each ink curve. This conversion consolidates editable source layers into one duotone layer, so we retained both the original file and converted project. A native duotone document is distinct from simply applying a brown color effect.

Find the mode-validation error after saving

Opening the exported PSD produced a color-mode error. Its header correctly contained duotone mode 8, 8-bit depth and one channel. However, the shared pixel reader accepted only mode 1 for a Gray request and rejected this valid duotone file.

We added a narrow condition in precision-psd.ts: mode 8 is accepted only for Gray, 8-bit depth, one channel and no separate alpha. Other mismatched modes, depths and alpha layouts remain rejected. A regression using the actual failed file reproduced the error before the fix and passed afterward.

app/studio/duotone.ts · SHA-256 b822fb75df4a · L9–14Photoshot implementation ↗
export type Duotone={inks:DuotoneInk[];overprints?:Record<string,string>;original?:string};
export const inkInputs=[0,5,10,20,30,40,50,60,70,80,90,95,100];
export const nullInkCurve=()=>[0,...Array(11).fill(-1),1000];
export const defaultDuotone=():Duotone=>({inks:[{name:'Black',color:'#000000',curve:nullInkCurve()},{name:'Warm Brown',color:'#98552e',curve:[0,...Array(11).fill(-1),650]}]});
const hex=(v:number[])=>'#'+v.map(n=>Math.round(Math.max(0,Math.min(1,n))*255).toString(16).padStart(2,'0')).join(''),rgb=(s:string)=>[1,3,5].map(i=>parseInt(s.slice(i,i+2),16)/255);
export function validateDuotone(d:Duotone){if(d?.original&&(typeof d.original!=='string'||d.original.length>87384))throw Error('Invalid preserved Duotone options.');if(!d||!Array.isArray(d.inks)||d.inks.length<1||d.inks.length>4)throw Error('Choose one to four inks.');for(const i of d.inks){if(typeof i.name!=='string'||!i.name.trim()||i.name.length>63||!/^#[0-9a-f]{6}$/i.test(i.color)||!Array.isArray(i.curve)||i.curve.length!==13||i.curve.some((n,j)=>!Number.isInteger(n)||n<((j===0||j===12)?0:-1)||n>1000))throw Error('Invalid ink name, color or curve.');}for(const [key,color] of Object.entries(d.overprints??{}))if(!Number.isInteger(+key)||+key<3||+key>15||!/^#[0-9a-f]{6}$/i.test(color))throw Error('Invalid overprint color.');}
PhotoshotDEVELOPMENT JOURNAL / 31IMPLEMENTATION

Inside the
calculation.

Verify the same file in the public editor

After releasing the import fix, we reopened the same PSD in the public editor. It opened as 2 Ink Duotone, preserving Black and Warm Brown names, colors and the 50%→35% and 100%→65% curve values. PSD/PSB round trips and existing RGB32 and Lab16 imports also passed.

The 524-byte ADO preset exactly matched the duotone options inside the PSD. Maximum grayscale difference through PSD was approximately 0.001961, within 8-bit quantization. The brown ink separation was a single-channel 16-bit TIFF, and every value matched an independent calculation from its ink curve.

Inspect CMYK composite and black plate separately

Save a Copy used the installed RSWOP.icm destination profile, Relative Colorimetric intent, Black Point Compensation and 300 ppi. We exported both a CMYK composite TIFF and Black Plate. The PSD retaining two duotone inks and the TIFF converted into four CMYK channels are different deliverables.

The TIFF contained 1536×1024 pixels, four 16-bit CMYK channels, 300 ppi tags and the exact 218,103-byte input ICC profile. Every black-plate pixel matched the K channel converted to grayscale on white paper. Full-resolution total ink was approximately 170.005% maximum and 122.617% mean.

PHOTOSHOT / SOURCE NOTES31

Read the code.

app/studio/precision-psd.ts · SHA-256 abedaf5b0299 · L19–25Photoshot implementation ↗
export function precisionCompositePixels(bytes:Uint8Array,space:PrecisionSpace,depth:PrecisionDepth,alpha:boolean,profile?:Uint8Array):PrecisionRaster{
 const n=precisionChannels(space),info=compositeInfo(bytes),{width,height,channels,large,compression}=info,size=width*height,needed=n+(alpha?1:0);
 // Duotone stores one 8-bit grayscale plane while keeping PSD color mode 8.
 const duotoneGray=info.colorMode===8&&space==='Gray'&&depth===8&&channels===1&&!alpha;
 if(size>16000000||channels<needed||info.depth!==depth||info.colorMode!==({RGB:3,Gray:1,CMYK:4,Lab:9})[space]&&!duotoneGray||depth===32&&!['RGB','Gray'].includes(space))throw Error('Unsupported flattened PSD dimensions, color mode, depth or channels.');
 const {planes}=compositePayloads(bytes,Array.from({length:needed},(_,i)=>i)),data=new Float32Array(size*(n+1));for(let i=n;i<data.length;i+=n+1)data[i]=1;
 for(let c=0;c<needed;c++){const payload=planes.get(c)!,values=decodePrecisionPlane(payload.data,compression,width,height,depth,large,payload.inflated);for(let i=0;i<size;i++)data[i*(n+1)+c]=space==='CMYK'&&c<n?1-values[i]:values[i];}
app/studio/duotone.ts · SHA-256 b822fb75df4a · L19–24Photoshot implementation ↗
/** Piecewise-linear ink curves provide predictable control without clipping intermediate knots. */
export function inkCoverage(curve:number[],input:number){const points=curve.flatMap((v,i)=>v<0?[]:[{x:inkInputs[i]/100,y:v/1000}]);const x=Math.max(0,Math.min(1,input));let i=1;while(i<points.length-1&&x>points[i].x)i++;const a=points[i-1],b=points[i];return a.y+(b.y-a.y)*(x-a.x)/(b.x-a.x);}
export function duotoneCanvas(r:PrecisionRaster,d:Duotone){validateDuotone(d);if(r.space!=='Gray')throw Error('Duotone needs a grayscale source.');const c=surface(r.width,r.height),g=c.getContext('2d')!,im=g.createImageData(r.width,r.height),inks=d.inks.map(i=>rgb(i.color)),colors=Array.from({length:1<<inks.length},(_,mask)=>{if(d.overprints?.[mask])return rgb(d.overprints[mask]);return [0,1,2].map(k=>inks.reduce((n,ink,j)=>mask&1<<j?n*ink[k]:n,1));}),table=Array.from({length:256},(_,tone)=>{const coverage=d.inks.map(i=>inkCoverage(i.curve,tone/255)),out=[0,0,0];for(let m=0;m<colors.length;m++){const weight=coverage.reduce((n,t,j)=>n*(m&1<<j?t:1-t),1);for(let k=0;k<3;k++)out[k]+=colors[m][k]*weight;}return out;});for(let i=0;i<r.width*r.height;i++){const gray=r.data[i*2]*r.data[i*2+1]+1-r.data[i*2+1],color=table[Math.round(Math.max(0,Math.min(1,1-gray))*255)];for(let k=0;k<3;k++)im.data[i*4+k]=color[k]*255;im.data[i*4+3]=255;}g.putImageData(im,0,0);return c;}
export async function convertDuotone(doc:StudioDoc,settings:Duotone,wasm?:Uint8Array):Promise<StudioDoc>{validateDuotone(settings);if(doc.duotone)return {...doc,duotone:structuredClone(settings)};const image=await composite(doc,true),pixels=await convertPrecision(floatCanvas(image),'Gray',8,undefined,wasm),src=(await precisionCanvas(pixels,wasm)).toDataURL();return {...doc,version:6,duotone:structuredClone(settings),precision:{space:'Gray',depth:8},layers:[makeLayer({kind:'image',name:'Duotone',width:doc.width,height:doc.height,src,precision:packPrecision(pixels),precisionSource:precisionSource(src)})],groups:undefined,artboards:undefined,artboardWorkspace:undefined,psdData:undefined,selection:undefined,savedSelections:undefined,spotChannels:undefined,layerComps:undefined,activeLayerCompId:undefined};}
export async function readDuotonePSD(bytes:Uint8Array,name:string,base:StudioDoc){const v=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);if(v.getUint16(22)!==8)throw Error('Duotone PSD requires 8-bit grayscale.');const length=v.getUint32(26);if(length>65536||30+length>bytes.length)throw Error('Invalid Duotone data length.');const resolution=readResource(bytes,1005),ppi=resolution&&resolution.length>=4?new DataView(resolution.buffer,resolution.byteOffset,resolution.byteLength).getUint32(0)/65536:72,duotone=readDuotoneOptions(bytes.subarray(30,30+length)),pixels=precisionCompositePixels(bytes,'Gray',8,false),src=(await precisionCanvas(pixels)).toDataURL();return {...base,resolution:ppi,name:name.replace(/\.(psd|psb)$/i,''),width:pixels.width,height:pixels.height,version:6 as const,duotone,precision:{space:'Gray' as const,depth:8 as const},layers:[makeLayer({kind:'image',name:'Duotone',width:pixels.width,height:pixels.height,src,precision:packPrecision(pixels),precisionSource:precisionSource(src)})],sourceFile:{name,mode:8,depth:8,warnings:[]}};}
export async function writeDuotonePSD(doc:StudioDoc,psb:boolean){if(!doc.duotone||doc.precision?.space!=='Gray'||doc.precision.depth!==8)throw Error('Duotone requires an 8-bit grayscale document.');const options=writeDuotoneOptions(doc.duotone),r=await compositePrecisionDocument(doc);for(let i=0;i<r.width*r.height;i++){r.data[i*2]=r.data[i*2]*r.data[i*2+1]+1-r.data[i*2+1];r.data[i*2+1]=1;}const header=new Uint8Array(30),v=new DataView(header.buffer);header.set(new TextEncoder().encode('8BPS'));v.setUint16(4,psb?2:1);v.setUint16(12,1);v.setUint32(14,r.height);v.setUint32(18,r.width);v.setUint16(22,8);v.setUint16(24,8);v.setUint32(26,options.length);const tail=new Uint8Array(4+(psb?8:4)+2),pixels=channelBytes(r,0),out=new Uint8Array(header.length+options.length+tail.length+pixels.length);out.set(header);out.set(options,header.length);out.set(tail,header.length+options.length);out.set(pixels,header.length+options.length+tail.length);const resolution=new Uint8Array(16),rv=new DataView(resolution.buffer),ppi=Math.round((doc.resolution??72)*65536);rv.setUint32(0,ppi);rv.setUint16(4,1);rv.setUint16(6,1);rv.setUint32(8,ppi);rv.setUint16(12,1);rv.setUint16(14,1);return writeResource(out,1005,resolution);}
app/studio/color-output.ts · SHA-256 8e4b90dbd19b · L10–17Photoshot implementation ↗
export function inkSummary(output:ColorOutput){if(output.profile.space!=='CMYK')return undefined;let max=0,mean=0;for(let i=0;i<output.pixels.length;i+=4){const sum=(output.pixels[i]+output.pixels[i+1]+output.pixels[i+2]+output.pixels[i+3])/65535*100;max=Math.max(max,sum);mean+=sum;}return {max,mean:mean/(output.width*output.height)};}
export async function colorOutputPreview(output:ColorOutput,plate=-1,wasmBinary?:Uint8Array){const cmyk=output.profile.space==='CMYK',out=new Uint8ClampedArray(output.width*output.height*4);if(cmyk&&plate>=0){if(!Number.isInteger(plate)||plate>3)throw Error('Choose a CMYK plate.');for(let i=0;i<out.length;i+=4){out[i]=out[i+1]=out[i+2]=255-output.pixels[i+plate]/257;out[i+3]=255;}return out;}const m=await loadICCEngine(wasmBinary),a=m.cmsOpenProfileFromMem(output.profile.bytes,output.profile.bytes.length),b=m.cmsCreate_sRGBProfile();let t=0;try{const n=cmyk?4:3;t=m.cmsCreateTransform(a,((cmyk?6:4)<<16)|(n<<3)|2,b,(4<<16)|(3<<3)|1,1,0x2000);if(!t)throw Error('This output profile cannot be previewed.');for(let start=0;start<output.width*output.height;start+=8192){const count=Math.min(8192,output.width*output.height-start),input=new Uint16Array(count*n);for(let i=0;i<count;i++)for(let k=0;k<n;k++)input[i*n+k]=output.pixels[(start+i)*4+k];const rgb=m.cmsDoTransform(t,input,count);for(let i=0;i<count;i++){out[(start+i)*4]=rgb[i*3];out[(start+i)*4+1]=rgb[i*3+1];out[(start+i)*4+2]=rgb[i*3+2];out[(start+i)*4+3]=cmyk?255:output.pixels[(start+i)*4+3]/257;}await new Promise(r=>setTimeout(r,0));}return out;}finally{if(t)m.cmsDeleteTransform(t);if(a)m.cmsCloseProfile(a);if(b)m.cmsCloseProfile(b);}}
/** Single-channel uncompressed 16-bit TIFF, BlackIsZero; ink 100% is black. */
export function separationTiff(output:ColorOutput,plate:number,dpi:number){if(output.profile.space!=='CMYK'||!Number.isInteger(plate)||plate<0||plate>3||!Number.isFinite(dpi)||dpi<1||dpi>1200)throw Error('Invalid separation output.');return gray16Tiff(Uint16Array.from({length:output.width*output.height},(_,i)=>65535-output.pixels[i*4+plate]),output.width,output.height,dpi);}
export function saveColorOutput(output:ColorOutput,dpi:number,plate=-1){return plate>=0?separationTiff(output,plate,dpi):tiff16(output.pixels,output.width,output.height,dpi,output.profile.bytes,output.profile.space==='CMYK');}
export const defaultOutputProfile=():PrintProfile=>({name:'sRGB IEC61966-2.1',bytes:srgbProfile(),space:'RGB '});

export async function convertDocumentColorOutput(doc:StudioDoc,profile:PrintProfile,options:ColorOutputOptions,cancelled:()=>boolean=()=>false,maxEdge?:number,wasmBinary?:Uint8Array):Promise<ColorOutput>{if(!doc.precision||doc.duotone){const c=await composite(doc,false,{cancelled,maxEdge});return convertColorOutput(c.getContext('2d')!.getImageData(0,0,c.width,c.height).data,c.width,c.height,profile,options,cancelled,wasmBinary);}validatePrintProfile(profile.bytes,profile.name);if(![0,1,2,3].includes(options.intent)||!Number.isFinite(options.dpi)||options.dpi<1||options.dpi>1200)throw Error('Invalid output settings.');const check=()=>{if(cancelled())throw new DOMException('Export cancelled','AbortError');};check();let raster=await compositePrecisionDocument(doc,wasmBinary);const n=precisionChannels(raster.space);for(const [name,spot] of Object.entries(doc.spotChannels??{})){if(spot.visible===false||!spot.solidity)continue;const plate=doc.savedSelections?.[name];if(!plate)continue;const rgb=spot.color.slice(1).match(/../g)!.map(v=>parseInt(v,16)/255),color=await convertPrecision({width:1,height:1,space:'RGB',depth:8,data:new Float32Array([...rgb,1])},raster.space,raster.depth,profileBytes(doc.precision.profile),wasmBinary),ink={...raster,data:new Float32Array(raster.data.length)};for(let i=0;i<raster.width*raster.height;i++){ink.data.set(color.data.subarray(0,n),i*(n+1));ink.data[i*(n+1)+n]=(1-plate.alpha[i]/255)*spot.solidity/100;}raster=compositePrecision(raster,ink);}if(maxEdge&&Math.max(raster.width,raster.height)>maxEdge){const ratio=maxEdge/Math.max(raster.width,raster.height),width=Math.max(1,Math.round(raster.width*ratio)),height=Math.max(1,Math.round(raster.height*ratio)),original=raster;raster=resamplePrecision(original,width,height,(x,y)=>({x:x*original.width/width,y:y*original.height/height}));}check();const cmyk=profile.space==='CMYK',converted=await convertPrecision(raster,cmyk?'CMYK':'RGB',16,profile.bytes,wasmBinary,{intent:options.intent,blackPoint:options.blackPoint}),channels=cmyk?4:3,pixels=new Uint16Array(converted.width*converted.height*4);for(let i=0;i<converted.width*converted.height;i++){const a=converted.data[i*(channels+1)+channels];for(let c=0;c<channels;c++)pixels[i*4+c]=Math.round(Math.max(0,Math.min(1,converted.data[i*(channels+1)+c]*(cmyk?a:1)))*65535);if(!cmyk)pixels[i*4+3]=Math.round(a*65535);}check();return {pixels,width:converted.width,height:converted.height,profile};}
PhotoshotDEVELOPMENT JOURNAL / 31IN PRACTICE

In the editor.

Separate inks, reopen the result / workspace
Actual CMYK TIFF settings: RSWOP.icm, 300 ppi.

Distinguish preview statistics from exported data

The reduced preview displayed maximum total ink of 171.8% and mean 122.6%. Its maximum differs from the full TIFF because statistics are computed on differently sized rasters. File figures in this article were calculated from the downloaded full-resolution pixels. The 300 ppi setting changed resolution metadata, not pixel dimensions.

A4 print preview with 10 mm margins and Scale to Fit placed the image at 190×126.7 mm. These checks cover editor previews, file structure and pixels, rather than measurements from a physical printer and paper. The reopening failure was resolved in code. Part 32 follows actual smart-object and linked-source edits.

PhotoshotDEVELOPMENT JOURNAL / 31BEFORE / AFTER

Original and reopened duotone.

Separate inks, reopen the result / original
01 / AI-generated demonstration original
Separate inks, reopen the result / result
02 / PSD reopened after fix · both inks restored

AI-generated study photograph. Editor captures and outputs show Photoshot in use.