PNG 캐릭터 카드 metadata:
tEXt 청크, JSON, 그리고 임포트 실패.
크리에이터를 위한 실전 가이드 — PNG 의 tEXt 청크를 둘러싼 여정입니다. Tavern 카드 포맷이 어떻게 평범한 이미지 한 장 안에 JSON 전체를 숨겨 넣는지, 왜 일부 호스트·이미지 편집기·CDN 이 그것을 조용히 버리는지, 그리고 망가진 metadata 를 어떻게 검증·보존·재구성할 것인지.
캐릭터 카드는 사실 그냥 PNG 입니다
Tavern 캐릭터 카드를 16 진수 뷰어로 열어 보면 첫 8 바이트는 89 50 4E 47 0D 0A 1A 0A — 표준 PNG 시그니처입니다. 브라우저로 열면 그저 한 장의 초상화. 같은 파일을 SillyTavern, RisuAI, Chub 에 드래그하면 이름·설명·인사말·로어북·시스템 프롬프트가 담긴 JSON 전체가 그림과 함께 나타납니다. 사이드카 파일은 없습니다. metadata 는 PNG 그 자체 안에, 모든 PNG 디코더가 군말 없이 지나가도록 규정된 자리에 들어 있습니다.
그 자리가 바로 tEXt 청크입니다. 이것이 무엇이며 무엇이 그것을 조용히 망가뜨리는지를 이해하는 것이, 모든 익스포트·업로드·CDN 을 거쳐도 살아남는 카드 라이브러리와, 친구의 컴퓨터에 도착했을 때에는 아무 성격도 남지 않은 텅 빈 초상화가 되어 버리는 카드의 차이를 만듭니다.
PNG 는 실제로 어떻게 만들어지는가
PNG 는 고정된 8 바이트 시그니처 뒤에 일련의 청크(chunk) 가 이어지는 구조입니다. 각 청크는 같은 골격을 따릅니다:
┌──────────┬──────────┬──────────────┬──────────┐ │ length │ type │ data │ CRC-32 │ │ 4 bytes │ 4 bytes │ length bytes │ 4 bytes │ └──────────┴──────────┴──────────────┴──────────┘
여기서 중요한 것은 4 바이트의 type 입니다. PNG 는 각 문자의 대·소문자로 처리 규칙을 부호화합니다:
- 첫 글자 대문자 → 크리티컬 청크(예: IHDR, IDAT, IEND). 디코더는 이해해야 합니다.
- 첫 글자 소문자 → 보조 청크. 디코더가 건너뛰어도 됩니다. 이미지 자체는 정상적으로 렌더링됩니다.
- 세 번째 글자는 예약되어 있어 현재는 항상 대문자.
- 네 번째 글자 소문자 → 복사 안전 — 편집기가 이 청크를 이해하지 못해도 보존해야 함. 대문자라면 편집기가 이미지를 청크를 무효화할 정도로 변경했을 때 청크를 제거해야 합니다.
tEXt 는 첫 글자가 소문자(보조·건너뛰기 가능), 네 번째 글자도 소문자(복사 안전) 입니다. 사양상으로는 점잖은 도구들 사이에서 무엇을 거치든 무탈하게 왕복해야 할 청크입니다 — "점잖은" 이 단어가 실제로 모든 무게를 짊어지고 있다는 것만 빼면.
tEXt, zTXt, iTXt — 세 가지 텍스트 청크
PNG 사양에는 사실 세 종류의 텍스트 청크가 있습니다. 커뮤니티는 캐릭터 카드를 위해 가장 소박한 쪽을 골랐는데, 그 이유는 알아 둘 가치가 있습니다:
- tEXt — Latin-1 키워드(1–79 바이트), 단일 null 구분자, 그리고 Latin-1 텍스트 값. 압축 없음, 언어 태그 없음. Tavern V1/V2 카드의 키워드는 문자 그대로 chara.
- zTXt — 레이아웃은 같지만 값이 zlib 압축됨. 큰 페이로드에 유용하나, 모든 작성자가 압축을 올바르게 구현해야 한다는 비용이 따라옵니다.
- iTXt — 현대판: UTF-8 텍스트, 선택적 압축, 선택적 언어 태그와 번역 키워드. 엄밀히 tEXt 보다 능력이 우수합니다.
그렇다면 사양상 더 강력한 iTXt 가 있는데 캐릭터 카드는 왜 tEXt 에 정착했을까요? 실용적 이유 두 가지입니다. 첫째, 모든 PNG 라이브러리가 오래 전부터 tEXt 를 지원해 온 반면, 가벼운/오래된 툴체인 일부는 iTXt 지원이 들쭉날쭉합니다. 둘째, 커뮤니티는 Latin-1 제약을 우회하기 위해 JSON 을 값에 직접 넣지 않고, UTF-8 JSON 을 Base64 로 인코딩한 뒤 그 ASCII 문자열을 tEXt 값에 저장하는 관행을 채택했습니다. ASCII 는 Latin-1 의 부분집합이므로, 읽는 쪽이 사양을 아무리 엄격하게 해석해도 인코딩 문제는 발생하지 않습니다.
Tavern 카드가 실제로 쓰는 것
구체적으로, Tavern 캐릭터 카드는 최종 IEND 청크 직전에 하나 혹은 두 개의 tEXt 청크를 기록합니다:
- chara — V1, V2 카드에서 사용. 값은 UTF-8 JSON 의 Base64. V2 에서는 JSON 이 spec: "chara_card_v2" 봉투를 갖고, 실제 데이터는 모두 data 아래에 들어갑니다.
- ccv3 — V3 에서 도입된 새 키워드. 값은 동일하게 UTF-8 JSON 의 Base64 지만, spec 이 "chara_card_v3". 최대 호환을 노리는 카드는 둘 다 싣습니다 — V2 형 페이로드의 chara 청크와, V3 형의 ccv3 청크.
따라서 최소한의 디코딩 절차는: 청크들을 차례로 훑어, type 이 tEXt 이고 키워드가 우선 ccv3, 차선으로 chara 인 것을 찾아 값을 Base64 로 디코딩하고, UTF-8 로 디코딩한 다음 JSON.parse. 마법도, 독자적 컨테이너도 없으며, 표준 PNG 디코더가 닿을 수 있는 범위 안에 있습니다.
metadata 는 어디에서 사라지는가
tEXt 는 보조 청크이기 때문에, 이미지를 재인코딩하는 도구는 사양상 그것을 버려도 됩니다. 실제로 많은 도구가 버립니다. "카드가 도착했을 때엔 텅 빈 얼굴" 이 되는 가장 흔한 시나리오:
- 이미지 편집기의 "다른 이름으로 저장". Photoshop, GIMP, Affinity, Pixelmator — 각자 보조 청크에 대한 정책이 다릅니다. 기본 설정은 대개 보존하지만, "PNG 로 내보내기", "Save for Web", 또는 스트리핑형 최적화 도구를 거치는 경로는 조용히 tEXt 를 떨궈 버립니다.
- 붙여넣기·다운로드 시 브라우저의 재인코딩. 스크린샷, canvas 익스포트, 백그라운드 페치 같은 일부 브라우저 파이프라인은 플랫폼의 PNG 라이터로 이미지를 재인코딩합니다. canvas API 는 tEXt 를 운반할 방법이 없으므로 canvas 왕복은 거의 항상 metadata 를 잃습니다.
- CDN 이미지 최적화. Cloudflare Polish, Vercel Image Optimization, Cloudinary auto-format 같은 서비스는 보조 청크를 군더더기로 취급해 적극적으로 재압축하며, 렌더링에 필수가 아닌 모든 것을 벗겨 냅니다. 카드 이미지가 /_next/image 경로나 Polish 가 켜진 CDN 을 거쳐 제공된다면, 방문자가 받는 파일은 당신이 업로드한 그 파일이 아닙니다.
- WebP / AVIF 변환. WebP 와 AVIF 는 자체 metadata 컨테이너(EXIF, XMP) 를 가지지만, 대부분의 변환기는 PNG 의 tEXt 를 그쪽으로 다리 놓아 주지 않습니다. 업로드를 WebP/AVIF 로 자동 변환해 배포하는 파이프라인은, 명시적으로 끄지 않는 한, 임베드된 카드 JSON 을 반드시 파괴합니다.
- 플랫폼의 스트리핑. 일부 소셜 플랫폼은 EXIF GPS 같은 프라이버시 유출을 제거하기 위해 업로드 이미지를 새니타이저에 통과시킵니다. 이런 새니타이저는 PNG 청크에 대해 대체로 무차별적이라, EXIF 뿐만 아니라 모든 보조 청크를 함께 제거하는 경향이 있습니다. Discord, Twitter, 프라이버시 프록시를 돌아 카드가 도착할 때엔 이미 벗겨져 있습니다.
- 보조 청크를 복사하지 않는 리사이즈 라이브러리. Sharp, ImageMagick 등은 모두 tEXt 를 보존할 수 있지만, 대부분의 파이프라인은 그 옵션을 켜지 않습니다. 모든 업로드 이미지에 대해 기본 설정 Sharp 로 썸네일을 만드는 파이프라인은 CDN 에 닿기도 전에 metadata 를 지워 버립니다.
카드를 검증하는 법
가장 빠른 새너티 체크는 pngcheck 입니다. 수십 년간 PNG 검증의 사실상 표준이었던 작은 CLI 입니다. -t 플래그로 텍스트 청크를 함께 출력합니다:
$ pngcheck -t card.png
OK: card.png (512x768, 32-bit RGB+alpha, non-interlaced, 87.4%).
chunk tEXt at offset 0x00021, length 4242, keyword: chara
chunk tEXt at offset 0x01a55, length 5180, keyword: ccv3이미지 크기만 보이고 텍스트 청크가 하나도 안 보인다면, metadata 는 이미 사라진 상태입니다. 거기서부터 프로그램적 검증은 Python 의 Pillow 로 다섯 줄 정도면 충분합니다:
from PIL import Image
import base64, json
img = Image.open("card.png")
raw = img.text.get("ccv3") or img.text.get("chara")
card = json.loads(base64.b64decode(raw))
print(card["data"]["name"])Pillow 는 모든 tEXt / zTXt / iTXt 청크를 키워드를 키로 삼아 동일한 img.text dict 에 노출합니다. img.text 가 비어 있다면 파일이 세탁된 것이고, Base64 디코드에서 예외가 나면 값을 Base64 가 아니라 원시 UTF-8 로 쓴 누군가가 있다는 뜻 — 일부 서드파티 도구에서 알려진 버그 패턴입니다.
metadata 를 살린 채로 보관·전송하기
카드가 여정을 무사히 견디게 하기 위한, 짧지만 단호한 체크리스트:
- 청크 보존형 도구를 고른다. 리사이즈에는 Pillow 의 img.save(..., pnginfo=...), Sharp 라면 .png({ force: true }).withMetadata() 에 수동 청크 복사를 더해서. 편집기는 "preserve metadata" 를 명시한 도구를 선호하고, 한 번 왕복할 때마다 pngcheck -t 로 검증한다.
- 카드 파일에 대해서는 CDN 이미지 최적화를 끈다. Cloudflare 에서는 카드 경로를 Polish 에서 제외하거나 cf-polish: off 를 붙인다. Vercel 에서는 /_next/image 를 우회하는 라우트로 제공한다. 정식 카드 파일은 "렌더링되는 자산" 이 아니라 "다운로드 산출물" 로 다뤄야 한다.
- 카드를 WebP/AVIF 로 절대 제공하지 않는다. Cloudinary, imgix 등의 auto-format 을 끈다. PNG 가 사양이 가정하는 전송 포맷이며, 그 외 형식은 의식적인 결정이어야 한다.
- PNG 옆에 JSON 백업을 둔다. PNG 는 친절한 배포 포맷이고, JSON 은 진짜 원본입니다. CDN, 플랫폼, 브라우저가 파일을 세탁했더라도 JSON 만 있으면 PNG 를 재구성할 수 있습니다. JSON 이 없으면 벗겨진 카드는 그대로 영영 사라집니다.
- 파이프라인을 바꿀 때마다 검증한다. 이미 정상인 테스트 카드에 대해 CI 에서 pngcheck -t 한 번을 돌리는 것만으로도, 사용자가 "임포트해 보니 비어 있더라" 라고 항의해야 비로소 발견되는 부류의 조용한 회귀를 통째로 차단할 수 있습니다.
한 권의 코덱스, 더 이상 세탁되지 않는 카드
tavernai.cards 는 이 모든 것을 대신 처리합니다: chara 와 ccv3 청크를 읽어 JSON 을 V2 와 V3 사양 양쪽으로 검증하고, 어떤 합리적인 재배포 경로를 거쳐도 살아남는 tEXt 청크를 가진 PNG 를 다시 써냅니다. 여정 도중에 metadata 를 잃은 라이브러리에는 JSON 백업으로부터 복원해 주는 컨버터를, 멀쩡해 보이는데도 어느 플랫폼에서는 임포트가 비어 버리는 카드에는 체인 어느 단계가 그것을 떨궜는지 짚어 주는 린터를 제공합니다.
"임포트하면 비어 있어요" 라는 사용자 신고를 한 명씩 디버깅하는 일은 이제 그만. 어느 CDN, 어느 컨버터, 어느 편집기가 방금 당신의 tEXt 청크를 먹었는지를 알려 주는 단 하나의 워크벤치로.