원본을 남겨 다시 현상하다 / original
DEVELOPMENT JOURNAL / 36
Photoshot
CODE × UI
무료 포토샵 개발기

원본을 남겨 다시 현상하다

보정한 PNG와 카메라 센서 RAW는 같은 입력이 아닙니다. 새 인물 사진에는 Camera Raw Filter를 적용하고, 센서 원본 보존·16비트 열기·재현상은 별도의 Bayer DNG로 검증했습니다. 실제 Sony ARW와 DCP 검사 결과도 함께 대조했습니다.

PHOTOSHOT DEVELOPMENT JOURNAL / 36

AI 생성 시연 이미지 · 실제 인물 촬영 및 공식 협업 이미지가 아닙니다. 편집 화면과 결과는 실제 포토샷에서 만들었습니다.

Photoshot무료 포토샵 개발기 / 36FOUNDATION

출처에서 시작하다.

보정 필터와 센서 디코더를 구분한다

이 글의 모델 사진은 생성한 PNG입니다. 공개 포토샷의 Camera Raw Filter에서 Exposure를 0.35로 지정해 적용했습니다. 저장 데이터의 raw.values.exposure가 0.35인지, 원본 src가 유지됐는지, 프로젝트를 다시 열어 같은 상태인지 확인했습니다.

이것은 이미 만들어진 RGB 사진에 보정 설정을 얹는 검사입니다. PNG 안에 센서 데이터가 생기는 것은 아닙니다. 센서 RAW 가져오기는 File의 Open Camera RAW 경로에서 LibRaw 워커로 바이트를 디코딩하는 별도 기능입니다.

RAW는 워커에서 풀고 카메라를 대조한다

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–14포토샷 구현 코드 ↗
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무료 포토샵 개발기 / 36IMPLEMENTATION

계산의
안쪽으로.

16비트 열기는 별도의 문서 경로다

새 UI 검사에는 96×64 합성 Bayer DNG를 사용했습니다. Open 16-bit Image로 열어 문서 정밀도가 16비트인지 확인했습니다. 기본 Open Image 경로의 8비트 sRGB 편집과 구분한 선택입니다. 화면에 보이는 8비트 미리보기만으로 내부 정밀도를 판정하지 않았습니다.

네이티브 16비트 문서는 현상된 채널 값을 별도 정밀도 데이터에 보관하며 16MP까지 허용합니다. 큰 사진에는 Half Size 안내가 있습니다. 8비트 결과를 16비트 파일로 내보내는 것만으로 사라진 센서 정보가 돌아오는 것도 아닙니다.

프로젝트 안에 센서 바이트와 해시를 남긴다

Keep RAW Original로 보관한 원본은 이름·바이트 수·Base64·SHA-256·현상 옵션을 가집니다. 내장 보관 상한은 32,000,000바이트로, RAW 가져오기의 100MB 상한과 다릅니다. 재현상 전에는 길이와 해시를 다시 확인해 원본 손상을 검출합니다.

이번 UI에서는 저장한 프로젝트를 다시 연 뒤 Re-develop Embedded RAW를 실행했습니다. Half Size로 재현상한 문서는 48×32였고 내장된 센서 바이트와 원본 해시는 유지됐습니다. 이 작은 센서 테스트의 화면을 별도로 실었습니다. 위 인물 PNG의 센서 원본이라고 설명하지 않았습니다.

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–19포토샷 구현 코드 ↗
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–8포토샷 구현 코드 ↗
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–7포토샷 구현 코드 ↗
// 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무료 포토샵 개발기 / 36IN PRACTICE

편집 화면에서.

원본을 남겨 다시 현상하다 / workspace
인물 PNG에 보정 필터를 설정한 실제 공개 포토샷 화면. 센서 RAW 검사는 아래에 별도로 표시했습니다.

실제 카메라 검사와 작은 테스트의 역할을 나눈다

별도로 다시 실행한 통합 검사는 실제 Sony ARW와 설치된 일치 DCP를 사용했습니다. 결과 크기는 3120×2084, 변경된 샘플은 64,794개였고 검사에서 집계한 클리핑 샘플은 106개였습니다. 원본 불변 검사도 통과했습니다. 이 결과를 모든 카메라의 지원이나 색 정확도 보증으로 확대하지 않았습니다.

작은 DNG는 저장·재열기·Half Size·원본 무결성을 반복 확인하기에 좋고, 실제 ARW는 센서 디코딩과 프로파일 연결을 확인합니다. 두 자료를 구분해 남겨 이후에도 같은 주장을 재검사할 수 있게 했습니다. 다음 37편에서는 Camera Raw 조절값과 XMP 프리셋을 이어서 다룹니다.

Photoshot무료 포토샵 개발기 / 36BEFORE / AFTER

원본과 편집 결과.

원본을 남겨 다시 현상하다 / original
AI 생성 PNG 원본. 카메라 RAW 파일이 아닙니다.
원본을 남겨 다시 현상하다 / result
Camera Raw Filter에서 Exposure 0.35를 실제 적용한 출력.

AI 생성 시연 이미지 · 실제 인물 촬영 및 공식 협업 이미지가 아닙니다. 편집 화면과 결과는 실제 포토샷에서 만들었습니다.