1 – 피그마 모션 투명배경으로 저장
피그마 모션을 제작한 동영상을 어도비 프로그램에서 알파채널을 인식할수 있게 내보내는 플러그인 이다.
2 – 코칭 프롬프트
## 역할
너는 30년차 풀스택 개발자다. Figma 플러그인, Node.js/Express, FFmpeg, TypeScript, QuickTime 컨테이너 내부 구조까지 다뤄본 사람으로서 이 프로젝트를 밑바닥부터 구현한다.
## 목표
Figma Motion으로 만든 애니메이션을 알파 채널이 살아있는 `MOV (QuickTime Animation, RGB+Alpha)`로 내보내, Premiere Pro에 그대로 임포트해 합성할 수 있게 하는 **개발용 Figma 플러그인 + 로컬 인코더 서버**를 만든다.
## 아키텍처
두 개의 프로세스로 구성된다.
1. **Figma 플러그인** (`src/code.ts` → `dist/code.js`, `src/ui.html` → `dist/ui.html`)
- Figma 문서에서 선택된 노드의 Motion 애니메이션을 시간축을 따라 샘플링해 프레임별로 PNG를 생성
- 각 PNG 바이트를 UI iframe으로 postMessage
- UI가 서버로 multipart form-data 업로드
2. **로컬 Node.js 인코더 서버** (`server.js`, port 8787)
- Express + multer + ffmpeg-static
- 업로드된 PNG 시퀀스를 `qtrle` 코덱 + `argb` 픽셀 포맷 + 전 프레임 키프레임(`-g 1`)으로 MOV 인코딩
- 인코딩 후 자체 검증: 스트림에 `argb|rgba|bgra` 픽셀 포맷이 있는가, MOV 컨테이너에 `stss` atom이 없는가
- 검증 통과 시 브라우저로 파일 반환
## 배경 (반드시 준수)
- Premiere Pro 12.1+는 **델타 프레임이 포함된 QuickTime Animation MOV를 임포트하지 못한다**. ffmpeg의 `qtrle` 인코더는 기본으로 프레임 간 델타 압축을 써서 일부만 키프레임으로 기록하고 MOV에 `stss` (sync sample) atom을 생성한다.
- 해결: `-g 1`로 **모든 프레임을 키프레임으로 강제**해서 `stss` atom이 아예 없게 만든다. 파일 크기는 커지지만 임포트가 안정된다.
- 픽셀 데이터에 우연히 `stss` 바이트가 나올 수 있으므로, 검증 로직은 문자열 검색이 아니라 **MOV atom 트리를 파싱**해야 한다.
## 프로젝트 파일 구성
```
manifest.json Figma 플러그인 매니페스트
package.json npm 스크립트, 의존성
tsconfig.json TypeScript 설정
.gitignore node_modules/, dist/, 로그, 개인 메모 제외
src/code.ts 플러그인 백그라운드 코드
src/ui.html 플러그인 UI (단일 파일, 스크립트 인라인)
scripts/cp.mjs build 시 src/ui.html → dist/ui.html 복사
server.js 로컬 FFmpeg 인코더
README.md 사용법
TESTING.md 회귀 검증 절차
premiere-test/ Premiere 임포트 비교용 샘플 3종 (선택)
```
## 의존성
- devDependencies: `@figma/plugin-typings`, `esbuild`
- dependencies: `express`, `cors`, `multer`, `ffmpeg-static`
## npm 스크립트
- `build`: `esbuild src/code.ts --bundle --target=es2018 --outfile=dist/code.js && node scripts/cp.mjs`
- `watch`: 코드와 UI 변경 모두 반영되도록 처음 한 번 `node scripts/cp.mjs` 실행 후 esbuild watch 시작
- `server`: `node server.js`
## manifest.json 요구사항
```json
{
"name": "Motion Alpha Exporter Premiere",
"id": "000000000000000000",
"api": "1.0.0",
"main": "dist/code.js",
"ui": "dist/ui.html",
"editorType": ["figma"],
"documentAccess": "dynamic-page"
}
```
## src/code.ts 구현 요구사항
### 진입점
- `figma.showUI(__html__, { width: 380, height: 520 })`
- 페이지 로드 시 선택 노드 정보를 UI로 전송 (`sendInfo`), `selectionchange` 이벤트에도 갱신
- `figma.ui.onmessage`로 `{ type: 'run', ... }` 메시지 수신
### 메시지 타입 정의
```ts
type Msg = {
type: 'run';
fps: number;
sec: number; // 0이면 모션 길이 자동
sc: number; // 배율
clear: boolean; // 루트 프레임 fill 제거
clearAll: boolean;// 내부 프레임 fill까지 제거
hideBg: boolean; // 배경 사각형 자동 숨김
name: string;
};
```
### run 흐름
1. 선택 노드가 있는지, clone 가능한지 확인
2. FPS는 1~60, sc는 0.25~4로 클램프
3. 모션 길이(`getDur`)를 재귀로 구해 자동 길이 계산
4. `src.clone()`으로 임시 노드 생성 → 원본에서 우측 300px 이동
5. 옵션에 따라 배경 처리 함수 호출 (아래 참고)
6. 원본과 클론의 노드 쌍(`Pair`)을 재귀로 매핑 (초기 x/y/w/h/opacity/rotation 기록)
7. `len = ceil(dur * fps)` 프레임 루프:
- 시간 t에 대해 모든 Pair에 애니메이션 값 적용 (`applyOne`)
- `tmp.exportAsync({ format: 'PNG', constraint: { type: 'SCALE', value: sc }, contentsOnly: true, useAbsoluteBounds: true, colorProfile: 'SRGB' })`
- 결과 바이트를 `figma.ui.postMessage({ type: 'fr', i, bytes })`로 전송
8. 종료 시 tmp 노드 삭제 (finally)
### 애니메이션 샘플링
노드 프로퍼티 `n.animations`가 `Record<string, Bind>` 형태로 존재한다고 가정하고 처리. 지원 채널:
- `TRANSLATION_X`, `TRANSLATION_Y`, `TRANSLATION_XY` (Vector)
- `OPACITY` (0~1)
- `ROTATION`
- `SCALE_X`, `SCALE_Y`, `SCALE_XY`, `WIDTH`, `HEIGHT`
각 Bind는 `baseValue`, `timelineDuration`, `tracks: Tr[]` 구조. 각 트랙은 `keyframeOperation: 'SET'|'OFFSET'|'SCALE'`과 정렬된 keyframes(`timelinePosition`, `easing`, `value`). 트랙을 순서대로 base에 겹쳐 적용하는 방식으로 최종값 산출.
### 이징 지원
`LINEAR`, `HOLD`, `EASE_IN`, `EASE_OUT`, `EASE_IN_AND_OUT`, `EASE_IN_BACK`, `EASE_OUT_BACK`, `CUSTOM_CUBIC_BEZIER`(cubic-bezier y1, y2 사용).
### 배경 처리 옵션
- `clear`: 선택 노드 자체의 `fills`/`backgrounds`/`fillStyleId`/`backgroundStyleId` 제거 (try/catch로 실패 무시)
- `clearAll`: 자식이 있는 모든 컨테이너 노드의 fill 제거 (walk)
- `hideBg`: 자식이 없는 RECTANGLE/VECTOR/ELLIPSE 중 아래 조건 만족 시 `visible = false`
- 이름이 `bg`/`background`/`backdrop`/`배경`/`백그라운드`/`흰배경`/`white bg` 매치
- 또는 부모 로컬 좌표계에서 부모 전체를 덮음
- 또는 root 절대좌표 기준 root 전체를 덮음
- 그리고 단색 fill이 opacity 0.95 초과로 보이는 상태
### 유틸리티
`clampNum`, `num` (숫자 아닐 때 default), `lerp`, `round`, `safeName`(`[\\/:*?"<>|]+`을 `-`로).
## src/ui.html 구현 요구사항
한 파일 안에 마크업+스크립트. CSS 장식 금지 (사용자 요구), 기본 HTML만 사용.
### UI 요소
- 선택 상태 표시 `#sel`
- 입력: `fps`(number, 기본 30), `sec`(number, 기본 0), `sc`(number, 기본 1), `name`(text, 기본 `motion-alpha`)
- 체크박스: `clear`(기본 체크), `clearAll`, `hideBg`(기본 체크)
- 실행 버튼 `#run`, 로그 영역 `<pre id="log">`
### 메시지 흐름
- `parent.postMessage`로 run 전송
- 수신: `info`, `start`, `fr`, `end`, `err`
- `fr` 수신 시 Uint8Array를 배열에 저장
- `end` 수신 시:
1. 첫/중간/끝 프레임을 canvas로 그려 알파 픽셀 존재 여부 검사 (`checkAlpha`)
2. 알파 없으면 원인·해결 안내 로그 표시하고 중단
3. 알파 있으면 FormData 구성 후 `fetch('http://localhost:8787/encode')` → Blob 다운로드 (`a.download = name + '.mov'`)
### 파일명 정제
`clean(s)` 함수에서 `\`, `/`, `:`, `*`, `?`, `"`, `<`, `>`, `|` 모두 필터. **정규식에서 `\\`로 이스케이프하는 것 잊지 말 것.**
## server.js 구현 요구사항
### 미들웨어
- `cors({ origin: '*' })` (Figma UI iframe origin이 null이라 와일드카드 필요)
- `multer({ storage: memoryStorage, limits: { files: 4000, fileSize: 30 * 1024 * 1024, fieldSize: 20 * 1024 * 1024 } })`
- files는 fps 60 × sec 60 = 3600을 상회하도록 여유 확보
### 라우트
- `GET /health` → `{ ok: true, name: 'motion-alpha-premiere-encoder', version: 'animation-rgb-alpha-2-allkeyframes' }`
- `POST /encode` (upload.array('frames')):
1. `os.tmpdir()` 아래 임시 폴더 생성
2. multer가 받은 파일들을 `originalname` 기준 정렬 후 `f%04d.png` 형식으로 디스크에 기록
3. ffmpeg 실행:
```
-y
-framerate {fps}
-i {dir}/f%04d.png
-an
-vf format=argb,pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0:color=black@0,format=argb
-c:v qtrle
-g 1
-pix_fmt argb
{out}
```
4. probe로 스트림 확인 → `/(argb|rgba|bgra)/i` 매치 없으면 500 반환
5. 파일 바이트를 읽어 `hasAtom(buf, 'stss')`가 true면 500 반환 (Premiere 임포트 실패 방지)
6. `Content-Type: video/quicktime`, `Content-Disposition: attachment` 헤더로 파일 전송
7. 임시 폴더 cleanup은 성공/실패 모두에서 실행
### hasAtom 파서 (핵심)
QuickTime/MOV atom 구조를 따라가는 재귀 파서:
- 각 atom은 `[size:uint32BE][type:4바이트]` 헤더로 시작
- `size === 1`이면 이어지는 8바이트가 실제 크기 (large size)
- `size === 0`이면 end-of-buffer까지가 크기
- 컨테이너 atom(`moov`, `trak`, `mdia`, `minf`, `stbl`, `edts`)만 자식으로 재귀
- depth 상한 8로 무한 재귀 방지
- target atom을 발견하면 true 반환
**이 파서를 반드시 구현해야 한다.** 문자열 검색으로 대체하면 픽셀 데이터의 우연 일치로 오탐이 나고, `-g 1`이 정상 동작해도 500이 뜬다.
### 그 외
- `clamp(n, min, max, d)`로 fps 방어
- ffmpeg 실행은 `spawn(ffmpegPath, args, { windowsHide: true })`로 하고 stderr 로그 12KB로 rolling 유지
- `p.on('close', code)` 논-제로면 에러 메시지에 마지막 로그 포함
## 파일별 최종 검증 체크리스트
- [ ] `npm run build` → `dist/code.js`, `dist/ui.html` 생성
- [ ] `npm run server` → 콘솔에 실행 로그 + `/health` 응답 version이 `animation-rgb-alpha-2-allkeyframes`
- [ ] Figma에서 `manifest.json` import → 플러그인이 목록에 나타남
- [ ] 애니메이션이 있는 프레임 선택 → UI에 이름과 길이 표시
- [ ] 내보내기 실행 → 로그가 `PNG 프레임 생성 → PNG 알파 확인됨 → 완료` 순서로 진행
- [ ] 다운로드된 MOV의 스트림에 `qtrle`, `argb` 확인
- [ ] MOV에 `stss` atom 없음 확인 (hasAtom 검사 통과)
- [ ] Premiere Pro에 임포트 성공, 시퀀스에서 아래 트랙이 비쳐 보임
## 하지 말 것
- UI에 CSS 장식 넣지 말 것 (기본 HTML만)
- `hasAtom`을 `buf.indexOf('stss')` 같은 문자열 검색으로 대체하지 말 것
- `-g 1` 옵션 빼지 말 것 (Premiere가 열지 못함)
- multer files 상한을 1000 이하로 두지 말 것 (긴 애니메이션에서 조용히 실패)
- watch 스크립트에서 ui.html 복사 단계 빠뜨리지 말 것
- 정규식에서 백슬래시 이스케이프 (`\\`) 누락하지 말 것
## 결과물
위 파일들을 실제로 만들고, `npm install`부터 Premiere 임포트까지 흐름이 통과하는지 검증한 뒤 결과를 보고할 것.