Photoshot development · 01
브라우저 편집기의 가역적 레이어 마스크: 문서 상태와 렌더링의 분리
React·PixiJS 연결 코드에서 공통 Canvas 합성기로 이어지는 구현 과정과 마스크 복원 실험.
공개 소스 기준: 초기 eead3ca / 후속 v0.16.0·19f45ed · 현재 운영 편집기의 모든 기능을 다룬 논문은 아님
초록
목표는 이미지·문자·브러시 레이어의 원본을 보존하면서 마스크로 표시 영역만 바꾸는 편집 기능이었다. 초기 PixiJS 연결 코드는 레이어를 그렸지만, 문서 저장과 마스크 복원까지 라이브러리가 대신 정의하지는 않았다. 이 글은 실제로 사용한 의존성, 직접 작성한 연결 코드, 후속 공통 합성기를 구분한다. 100×100픽셀 진단 실험에서는 원본을 직접 지운 대조군과 문서 마스크를 비교했다. 문서 마스크를 끄면 원래 RGBA가 정확히 복원됐고, 소스 수준 검사 5개를 통과했다. 과거에 없던 실패를 꾸민 사례나 Photoshop 전체 호환성 주장은 아니다.
1. 구현 목표와 완료 기준
이번 기능의 목표는 여러 이미지를 캔버스에 겹쳐 보이는 데서 끝나지 않는다. 레이어마다 위치·크기·회전·불투명도·합성 모드를 독립적으로 유지해야 한다. 마스크로 일부를 가려도 원본은 남아 있어야 하고, 마스크를 끄면 가리기 전 모습이 돌아와야 한다. 같은 문서를 저장 구조로 직렬화하고 합성 결과로 출력하는 것까지 기능의 계약으로 정했다.
이를 위해 영속 문서, 레이어별 임시 표면, 화면에 표시하는 렌더 객체를 구분한다. React는 화면과 상태를 구성하고, 레이어 모델은 편집값과 마스크 스트로크를 보관한다. 멋진 합성 화면 한 장만으로 세 객체의 일관성이 입증되지는 않는다.1
2. 초기 코드는 어떻게 작성했고 어디서 막혔는가
초기 공개 커밋 eead3ca는 PixiJS의 Application·Sprite·Texture를 가져온다. 표시할 레이어마다 Sprite를 만들고 문서의 편집값을 속성에 연결했다. 회전값은 도에서 라디안으로 바꾸고, 중심 앵커에 맞춰 좌표에 너비·높이의 절반을 더한다. 설치한 라이브러리의 API를 직접 사용하되 연결 코드는 별도로 작성한 방식이다.
구현의 장애물은 그래픽 객체와 편집 가능한 문서 사이의 경계였다. Sprite 자체는 포토샷의 마스크 이력·저장 형식·내보내기 규칙을 정의하지 않는다. 초기 Stage는 PixiJS, 내보내기는 별도 Canvas 루프를 사용했다. 소스에서 확인되는 것은 경로가 나뉘었다는 사실이다. 이를 모든 이미지에서 실제 불일치가 측정됐다는 과거 실패로 부풀리지 않는다.1
eead3ca'use client';
import { useEffect, useRef } from 'react';
import { Application, Sprite, Texture } from 'pixi.js';
import 'pixi.js/advanced-blend-modes';
import { layerSurface, type StudioDoc } from './document';eead3ca const s = new Sprite(Texture.from(canvas));
s.anchor.set(0.5);
s.position.set(l.x + l.width / 2, l.y + l.height / 2);
s.rotation = (l.rotation * Math.PI) / 180;
s.alpha = l.opacity;
s.blendMode = l.blend;
a.stage.addChild(s);3. GitHub에서 무엇을 가져왔는가
React와 PixiJS는 실제 설치해 사용한 기반이다. PixiJS 사용 근거는 참고 목록의 저장소 이름이 아니라 초기 커밋에 남아 있는 import와 Sprite 생성 코드다. 반면 layerSurface 호출, 좌표 연결, 문서 스키마는 포토샷에서 작성한 연결 부분이다. 외부 사진 편집기 전체를 가져와 이름만 바꾼 구현이 아니다.23
Photopea의 마스크 문서는 마스크를 껐을 때 내용이 복원돼야 한다는 동작을 비교하는 자료로 사용했다. 이를 Photopea의 소스 코드를 복사했다는 의미로 쓰지 않는다. 후속 합성기는 포토샷 자체 커밋 19f45ed를 근거로 설명하며, 초기 PixiJS 구현과 책임을 분리한다.4
| Source | Use category | Contribution | Boundary |
|---|---|---|---|
| React | 설치 의존성 | 화면 구성과 상태 | 완성된 편집기 복제 아님 |
| PixiJS | 초기 Stage의 설치 의존성 | Application·Sprite·Texture API | 후속 Stage는 Canvas 합성 |
| Photopea Masks | 동작 참고 문서 | 마스크를 끄면 원본 복원 | 소스 이식 아님 |
| Photoshot | 애플리케이션 코드 | 문서·연결·마스크·공통 합성 | 정확한 커밋을 아래에 인용 |
의존성 사용·문서 참고·자체 코드를 서로 다른 주장으로 기록했다.
4. 해결 코드: 원본을 남기고 임시 표면에 마스크 적용
layerSurface는 렌더마다 Canvas를 만들고 원본 또는 브러시 스트로크를 그린 뒤 destination-in으로 마스크를 적용한다. 마스크의 알파만큼 임시 목적지 픽셀이 남는다. 적용 뒤 합성 모드는 source-over로 되돌린다. 원본 스트로크와 마스크 스트로크는 문서의 별도 필드이므로, 마스크를 끄면 보존된 원본에서 표면을 다시 만들 수 있다.
후속 Stage의 미리보기는 공통 composite 함수를 호출한다. 레이어 순서·변환·합성·조정의 책임을 문서 합성 경로에 모았고, 미리보기의 긴 변은 1,600픽셀로 제한한다. 더 새로운 편집이 들어오면 revision 조건으로 오래된 계산을 취소한다. 같은 합성기를 쓰더라도 미리보기와 원본 크기가 다를 수 있으므로, 해상도까지 맞춘 동등성 검사는 별도로 필요하다.5
19f45ed if (layer.mask?.enabled) {
ctx.globalCompositeOperation = 'destination-in';
ctx.drawImage(maskSurface(layer.mask), 0, 0, c.width, c.height);
ctx.globalCompositeOperation = 'source-over';
}19f45ed const image = await composite(d, false, {
maxEdge: 1600,
cancelled: () => !mounted.current || r !== revision.current,
});
if (!mounted.current) break;v0.16.0 소스를 확인해 그린 구조도다. 초기 PixiJS Stage는 코드 1·2에서 별도로 다룬다. 화살표는 데이터 흐름이며 소요 시간을 뜻하지 않는다. 미리보기와 내보내기는 같은 합성기를 호출하지만 요청 해상도가 다를 수 있다.5. 진단 방법과 관찰 결과
이번 개정에서는 공개 문서 엔진을 수정하지 않고 Node와 @napi-rs/canvas로 실행했다. 입력은 파랑·주황 스트로크를 가진 100×100픽셀 레이어이며, 지름 40픽셀 마스크 스트로크로 중앙을 가린다. 원본·마스크 적용·마스크 해제 표면을 비교한 뒤 문서를 JSON으로 직렬화하고 검증·재합성했다. 렌더 전후 원본 문서도 대조했다.
실패 대조군은 Canvas 원본의 중앙을 직접 지운 뒤 그 지우기 동작만 끄도록 의도적으로 구성했다. 이미 지운 픽셀은 동작을 끈다고 돌아오지 않는다. 이 대조군은 이번에 만든 진단용 구현이며 과거 포토샷 초판이 아니다. 중앙 알파는 대조군에서 0으로 남았지만, 문서 마스크를 끄면 255로 돌아왔다. 해제 결과의 전체 RGBA 배열은 원본과 같았고, JSON 왕복 후 마스크 결과도 저장 전 합성과 일치했다.6
| Check | Observation | Result |
|---|---|---|
| 렌더 전후 원본 문서 | JSON 동일 | PASS |
| 마스크 중앙 | 알파 0 | PASS |
| 중앙 복원 스트로크 | 알파 255 | PASS |
| 마스크 해제 | 전체 RGBA가 원본과 동일 | PASS |
| JSON 직렬화 → 검증 → 합성 | 마스크 적용 결과와 동일 | PASS |
명시한 합성 입력에서 실행한 결정적 검사 5개다. JSON 왕복은 IndexedDB 내구성 검사가 아니며, 이번 재실행으로 브라우저·Photoshop 동등성을 입증하지 않는다.
6. 결과의 의미와 다음 과제
입증한 범위는 이 소스 버전이 해당 입력의 원본을 보존하고 마스크 해제 시 다시 그릴 수 있다는 것이다. 모든 파일 형식·브라우저·GPU·변환 마스크·합성 모드가 Photoshop과 일치한다는 검사는 아니다. 네이티브 Canvas 기반 실험이므로 실제 브라우저 조작, IndexedDB 저장 후 재실행, 원본 해상도 내보내기 검사와도 구분한다.
구현의 변화는 라이브러리 이름 교체가 아니다. React와 초기 PixiJS를 재사용하고, 문서 연결·마스크 적용을 직접 작성한 뒤 미리보기를 자체 공통 합성기로 모았다. 2편에서는 그럴듯한 밝기·대비 식이 시험 차트에서 어긋났을 때, UI 장식이 아니라 측정 응답과 연산 순서를 바꿔 해결한 과정을 다룬다.
코드·데이터와 재현 자료
변경하지 않은 공개 소스, 코드 발췌의 정확한 위치, 진단 결과를 확인할 수 있다. 실험 코드는 포토샷 문서 엔진을 가져와 실행하는 별도 하네스다.