- register_edit(편집본 ECC 정렬·챔퍼 지표), normalize_base(P1 머리 크기 정규화, 위쪽 종이색 채움) - faceDetail region=face(얼굴 해칭을 원화 픽셀로 보존)·matchFacelessColor - brow_sprite 단계: 원화 눈썹 잉크 스프라이트(hairFront 아래 제외), 윗눈꺼풀 두께·홍채 실측 - 리그 계약 RigBrow.halfWidth/sprite·RigEye.lineScale, 렌더러 눈썹 16띠·바깥쪽 눈꺼풀 굵기 - 실행기 UTF-8 하위 실행, brow_centerline 패딩본 자체 생성, QA 시트 촬영 도구(깜빡임 회피·고해상도 대조) - P1은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
23 KiB
공통 리노컷 아바타 파이프라인
아바타 v3 리노컷 리그(결정문 avatar-expression-engine-v3.md
§8.2 리그 원칙)의 자산 파이프라인이다. P1~P7 등 모든 페르소나가 이 공통 스크립트를 공유하고,
페르소나별 차이는 각 페르소나 폴더(예: ../p1-linocut/)의 persona.json에서만 읽는다.
결과물(레이어 픽셀, 알파, 게시된 WebP, 리그 TS)은 페르소나별 폴더 구조나 상수 위치가 바뀌었다는 이유로 달라지지 않는다 — 알고리즘은 건드리지 않았다(P1 회귀 검증: 아래 참고).
단계와 의존 순서
run_pipeline.py가 아래 순서로 각 단계를 독립 프로세스로 실행한다(괄호는 산출물):
landmarks.py— base-front.png 랜드마크 검출 (manifest.landmarks)brow_centerline.py—manifest.landmarks.eyebrowLeft/Right를 잉크 띠 중심선으로 보정segmentation.py—layers/{body,head,hairFront}.png(v1) + manifest 기준 섹션layers_v2.py—layers/v2/{body,head,hairFront}.png(턱 밑 띠·잔머리 halo 보정)face_detail.py—layers/v2/face-detail.pngbrow_sprite.py—layers/v2/brow-{left,right}.png(원화 눈썹 스프라이트) +manifest.eyeLineScale(눈꺼풀 선 굵기 배율) +manifest.iris(홍채 실측, 플래그와 무관하게 항상 기록). 아래 "browSpriteFromArt/eyeLineScaleFromArt/irisFromArt" 절 참고.paper_grain.py—layers/v2/paper-grain.png(다른 단계와 독립, 순서 유연)lip_texture.py—layers/v2/lip-{upper,lower,shadow}.pngjaw_pieces.py—layers/v2/jaw-{head,detail}.pngexport_rig.py— WebP 게시(apps/web/public/avatar/v3/<publicSlug>/) +rigs/<rigFileName>생성final_previews.py— 게시된 WebP로 모션·눈/입 확대 미리보기
이 순서는 전달받은 작업 설명의 번호(주제별 묶음)와 다르다 — 특히 final_previews는
export_rig가 쓴 export-rig-report.json을 읽으므로 반드시 export_rig 다음이어야
한다. face_detail·jaw_pieces는 manifest.landmarks(12단계가 채움)와
4단계)가 먼저 있어야 한다.layers/v2/{head,hairFront,body}.png(3
실행
# 전체 실행
<venv>/python.exe run_pipeline.py <persona-dir>
# 한 단계만 다시 실행
<venv>/python.exe run_pipeline.py <persona-dir> --only face_detail
# 중간부터 끝까지
<venv>/python.exe run_pipeline.py <persona-dir> --from lip_texture
# 단계 이름 목록
<venv>/python.exe run_pipeline.py --list
각 단계 스크립트는 python <script>.py <persona-dir>로 단독 실행도 된다(디버깅용).
주의: landmarks.py만 혼자 다시 돌리면 manifest.landmarks를 통째로 새로 써서
brow_centerline.py가 보정한 눈썹 중심선이 사라진다. --only landmarks를 쓴 뒤에는
--only brow_centerline도 반드시 같이 돌려야 한다(자동으로 뒤따라 돌지 않는다).
persona.json 스키마
페르소나 폴더(예: ../p1-linocut/persona.json)에 둔다. 랜드마크·분할로 계산 가능한
값은 각 단계 스크립트가 직접 계산하므로 여기 없다 — 원화마다 달라지고 유도할 수 없는
값(참조 이미지를 보고 사람이 고른 점·상자)만 이 파일에 둔다.
{
"code": "P1", // 필수. manifest.persona, rig.persona
"publicSlug": "p1", // 생략 시 code.lower(). apps/web/public/avatar/v3/<publicSlug>/
"rigFileName": "p1Rig.ts", // 생략 시 "<publicSlug>Rig.ts"
"rigExportName": "P1_LINOCUT_RIG", // 생략 시 "<CODE>_LINOCUT_RIG"
// 모티프 팔레트(motifPetal/motifLeaf) 표본을 뽑을 스타일 참조 이미지(persona.json 기준 상대경로).
// export_rig.py의 팔레트 계산에서만 쓴다 — 모티프 스프라이트 자체는 이번 파이프라인
// 범위 밖이다(오케스트레이터가 따로 설계).
"styleFrame": "../art-direction-v3/p1/r2-b-linocut.png",
"paletteSamples": {
// 머리카락 어두운 덩어리 표본(ink 팔레트색). base-front.png 픽셀 기준 상자.
"ink": { "box": [280, 100, 720, 350], "lumThreshold": 55 },
// 눈 흰자/홍채/홍채테 고정 설계값(결정문 §8.2 "고정값" 원칙) — 생략하면 공통 기본값 사용.
"eyeOverride": { "sclera": "#D8CEBD", "iris": "#4F3B2C", "irisRing": "#1E1F1F" },
// styleFrame에서 꽃잎(ochre)·잎/구름(blue) 색을 뽑을 상자들. kind는 "ochre" 또는 "blue".
"motifPetalBoxes": [{ "label": "sun", "kind": "ochre", "box": [1230, 10, 1536, 210] }],
"motifLeafBoxes": [{ "label": "cloudLeft", "kind": "blue", "box": [20, 20, 380, 190] }]
},
"faceDetail": {
// 점(기미) 등 랜드마크로 안 나오는 얼굴 반점. 없으면 빈 배열(점 없는 캐릭터도 된다).
"moles": [{ "center": [661.3, 627.9], "radius": 20.0 }],
// "features"(기본)면 눈·눈썹·입 주변 타원만 faceDetail(원화 픽셀) 범위다. "face"면
// 얼굴 오벌 전체(moles 포함)로 넓어진다 — 캐스트 확장(새 페르소나)은 "face"를 쓴다.
// 자세한 설계는 아래 "faceDetail.region" 절 참고.
"region": "face",
// region="face"일 때만 쓴다. 얼굴 오벌 경계에서 안쪽으로 줄이는 폭(px, 기본 8) —
// 원화 윤곽 잉크선이 faceDetail 범위 밖에 남도록 원화를 보고 정한다.
"faceInsetPx": 8,
// true면 제외 영역(눈·눈썹·입) 안을 채우는 faceless(F) 픽셀의 색을 주변 원화
// 피부에 맞춰 평행이동한다(기본 false). 아래 "faceDetail.matchFacelessColor" 절 참고.
"matchFacelessColor": true
},
// 렌더러 회전/스케일 중심점(결정문 §8.4). 원화를 보고 목·몸통·얼굴 중심을 정한다.
"pivots": { "neck": [500, 990], "body": [502, 1566], "face": [490, 660] },
// bust 크롭은 정사각형(변 = 캔버스 폭)이고 위쪽 오프셋만 여기서 정한다.
"crops": { "bustYOffset": 40 }
}
eyeOverride·paletteFixed(mouthInner/teeth/blush/tear/pallor/paper)·backdrop(겉표정
그룹별 배경색)은 모든 페르소나가 공유하는 기본값이 있다(persona_config.py의
DEFAULT_*) — 캐릭터마다 다르게 할 필요가 있을 때만 persona.json에 적어 덮어쓴다.
styleFrame·paletteSamples.ink·paletteSamples.motifPetalBoxes/motifLeafBoxes·
pivots는 필수다(export_rig.py가 해당 값을 쓰는 시점에 없으면 어떤 필드를
채워야 하는지 알려주며 멈춘다).
faceDetail.browLandmarksOverride — P1 전용 호환 장치, 새 페르소나는 쓰지 않는다
"faceDetail": {
"browLandmarksOverride": {
"browLeft": { "inner": [..], "peak": [..], "outer": [..] },
"browRight": { "inner": [..], "peak": [..], "outer": [..] }
}
}
있으면 face_detail.py가 눈썹 제외 영역·눈 영역 y0 계산에 manifest.landmarks의
현재(중심선 보정) 눈썹 좌표 대신 이 값을 쓴다. 다른 랜드마크(눈·입·코·턱)는 그대로
현재 값을 쓴다 — 눈썹만 바꾼다.
P1의 기존 face-detail.png·jaw-detail.png·해당 webp·p1Rig.ts는 눈썹 중심선 보정
(manifest.browCenterline) 이전 좌표(browCenterline.oldPoints)로 빌드된 뒤
"다시 빌드하지 않는다"는 오케스트레이터 지시로 고정됐다(소유자도 그 결과를 검수했다).
그래서 P1 persona.json에는 browCenterline.oldPoints와 같은 값을 넣어 재실행 결과가
그 고정본과 바이트 단위로 같아지게 한다. 새 페르소나는 이 필드를 넣지 않는다 —
처음부터 중심선 보정 좌표로 빌드되므로 과거 좌표를 따로 고정할 이유가 없다.
browHalfWidthFromArt — 눈썹 굵기를 원화에서 잴지 여부(기본 true, 캐스트 확장)
{
"browHalfWidthFromArt": false
}
렌더러(v3/linocutGeometry.ts buildBrow)의 눈썹 반두께는 기본으로 P1 설계값
(BROW_STOPS = [4.5, 3.2, 1.0])이 아니라 원화에서 실측한 값을 쓴다
(brow_centerline.py가 눈썹 잉크 띠의 중심선 법선 방향 반두께를 안쪽·꼭짓점·바깥
세 점에서 재 manifest.browCenterline.halfWidth{Left,Right}에 남기고,
export_rig.py가 browHalfWidthFromArt가 false가 아니면(필드 생략 시 기본 true)
그 값을 rig.landmarks.browLeft/Right.halfWidth로 내보낸다). 굵은 눈썹 캐릭터(성인·
남성 등)가 P1 고정 굵기로 그려져 원화와 달라지는 문제를 캐스트 확장에서 없앤다.
P1은 false로 둔다 — 기존 p1Rig.ts가 2단계-B에서 소유자 검수를 거쳐 고정됐으므로
(§8.4 눈썹 항목) 재실행해도 바뀌면 안 된다. 측정값 자체는 browHalfWidthFromArt
설정과 무관하게 항상 manifest에 남는다(참고용). 새 페르소나는 이 필드를 생략한다
(기본 true로 원화 실측값을 쓴다).
browSpriteFromArt/eyeLineScaleFromArt/irisFromArt — 눈썹 스프라이트·눈꺼풀 굵기·홍채(모두 기본 true)
{
"browSpriteFromArt": false,
"eyeLineScaleFromArt": false,
"irisFromArt": false
}
굵은 판화 눈썹 캐릭터는 벡터 눈썹(P1 설계 굵기에 맞춘 리본·털 획)이 "갈색빛 회색 막대"로 그려진다(결정문 §8.4.1). 입술과 같은 "중립 렌더 = 원화" 원칙을 적용한다:
-
browSpriteFromArt(기본 true):brow_sprite.py가 눈썹 중심선(manifest.landmarks. eyebrowLeft/Right+browCenterline.halfWidth{Left,Right})을 따라 띠를 만들고, 그 안의 원화 잉크(Otsu+diff 연결성분,brow_centerline.py와 같은 기준)에서 머리카락 (layers/v2/hairFront.png알파 — 분할 카테고리 자체가 아니다. 이 화풍에서는 mediapipe 머리카락 분할이 두꺼운 눈썹 잉크 자체를 머리카락으로 오탐해, 분할 결과를 그대로 빼면 눈썹 잉크 태반이 사라진다. 실제로 화면에서 눈썹 위에 겹쳐 그려질 hairFront 알파로 기준을 좁혀야 "앞머리 가닥이 눈썹과 같이 움직이면 안 된다"는 의도를 과하게 지우지 않고 구현할 수 있다)를 뺀 자리만brow-{left,right}.png로 뗀다.export_rig.py가 입술 결과 같은 무손실(exact) WebP로 게시하고rig.landmarks.browLeft/Right.sprite를 채운다 — 렌더러(linocutGeometry.tscomputeBrowSpriteStripTransforms)가 벡터 몸통·털 획 대신 이 스프라이트를 세로 16띠로 나눠 옮긴다(안쪽 올림 그늘은 그대로 벡터로 그린다). -
eyeLineScaleFromArt(기본 true):brow_sprite.py가 눈 폭 가운데 60% 열마다upperLidTop.y-12~홍채 중심 y 사이 첫 연속 어두운(lum<80) 구간 길이의 중앙값을 재, P1 기준값(2.0px, 상수로 고정 — 근거는brow_sprite.py주석 참고)으로 나눈 비를[1.0, 2.5]로 clamp한 값을manifest.eyeLineScale에 남긴다.export_rig.py가rig.landmarks.eyeLeft/Right.lineScale로 내보내고, 렌더러가 눈꺼풀·쌍꺼풀· 아랫눈꺼풀 굵기에 곱한다(홍채·흰자는 그대로). -
irisFromArt(기본 true): mediapipe iris 랜드마크(468477)는 실제 사람 눈 비례로 학습돼, 판화 잉크로 그려진 홍채보다 반지름을 크게(또는 중심을 다르게) 잡을 수 있다 (2차 보정 T3, P7 실측: 가로 반지름 기준 리그 대비 약 1115% 작다).brow_sprite.py가 (browSpriteFromArt/eyeLineScaleFromArt와 무관하게 항상) 원화에서 홍채 가로 반지름· 중심을 재manifest.iris에 남기고,export_rig.py가irisFromArt가false가 아니면rig.landmarks.eyeLeft/Right.iris를 이 실측값으로 채운다(세로 경계는 윗눈꺼풀 잉크와 홍채가 같은 톤으로 이어져 가를 수 없어, 가로 반지름 + 아래쪽 경계로만 중심y를 역산한다 — 자세한 방법은brow_sprite.pymeasure_iris참고).
P1은 셋 다 false다 — 이미 고정 벡터 눈썹·굵기·홍채 위치로 빌드·검수됐고,
eyeLineScaleFromArt의 경우 P1 자체가 lineScale의 기준값이라 바뀌면 안 된다.
새 페르소나는 이 필드들을 생략한다(기본 true로 원화 스프라이트·실측 굵기·실측
홍채를 쓴다).
faceDetail.region — faceDetail이 그려질 범위(기본 "features", 캐스트 확장 "face")
face_detail.py가 눈·눈썹·입 "제외 영역"(이목구비 잉크선) 밖에서 원화(base-front) 픽셀을
쓰는 범위를 정한다. 레이어 픽셀은 항상 base-faceless(F)에서 가져오므로(README 상단
단계 설명 참고), 이 범위 밖은 F뿐이라 — 다크서클·볼 해칭·팔자 주름·턱 그늘 같은
원화 고유의 판화 해칭이 전혀 남지 않는다.
"features"(기본, P1 그대로): 눈·입 주변 타원(눈두덩~다크서클, 입가 음영)에서만 원화 픽셀을 쓴다. 나머지 얼굴 피부는 F(매끈한 기본형)다."face"(캐스트 확장): 얼굴 오벌(export_rig.compute_face_oval, 다른 단계와 같은 36점 루프·1.04 배율) 전체에서 원화 픽셀을 쓴다. moles 타원은 그대로 합쳐진다.- 오벌을 그대로 쓰면 원화 얼굴 윤곽 잉크선(jaw_pieces.py 기준 오벌 경계 ±18px
안팎)이 영역 안에 들어와, F 기준으로 이미 그려진 head 레이어의 윤곽선과 겹쳐
두 겹 턱선(머리 이동 시차)이 생긴다 —
faceInsetPx(기본 8)만큼 오벌을 안쪽으로 줄여(거리변환 기준, 방사형 스케일이 아니다) 이 잉크선을 영역 밖에 둔다. 페더는 다른 영역과 같은REGION_FEATHER_PX(12px, sigma 6)를 쓴다. 새 페르소나마다 원화 윤곽선 위치가 다르므로, 원화를 보고faceInsetPx를 정해 보고해야 한다. - 머리카락 알파 0 규칙(머리카락 위에는 faceDetail을 그리지 않는다)은 그대로이되,
"face"에서는 머리카락 마스크를 원화(front)와 faceless(F) 분할 결과의 합집합으로 만든다. 둘의 앞머리 가닥 위치가 달라(codex 편집본 시차) 한쪽 분할만 쓰면 다른 쪽 잔머리가 마스크 밖에 남아 유령 가닥이 생기기 때문이다."features"는 기존처럼 F 분할만 쓴다(P1 회귀 보존).
- 오벌을 그대로 쓰면 원화 얼굴 윤곽 잉크선(jaw_pieces.py 기준 오벌 경계 ±18px
안팎)이 영역 안에 들어와, F 기준으로 이미 그려진 head 레이어의 윤곽선과 겹쳐
두 겹 턱선(머리 이동 시차)이 생긴다 —
manifest.layersV2.faceDetail.region/faceInsetPx에 남는다.
faceDetail.matchFacelessColor — 제외 영역 F 색을 원화 피부에 맞추기(기본 false)
true면 제외 영역(눈·눈썹·입) 안을 채우는 F 픽셀의 색을 주변 원화 피부에 맞춘다.
영역(eyeLeft/eyeRight/browLeft/browRight/mouth)마다 그 영역 바깥 RING_PX(12px)
고리에서 어두운 잉크(원화 명도<110) 픽셀을 뺀 나머지로 원화·F 평균 RGB 차(ΔRGB)를
구하고, 그 영역 전체의 F 픽셀을 ΔRGB만큼 그대로 평행이동(가산)한다 — 보간·블러·
패치 합성은 하지 않는다(과거 P1에서 그 방식이 회색 얼룩으로 반려된 적이 있다).
영역별 ΔRGB는 콘솔과 manifest.layersV2.faceDetail.matchFacelessColorDeltaRgb에
남는다. P1은 false(기존 결과 그대로)다.
캐스트 확장 0단계 — 원화 3장을 준비하는 공통 도구
새 페르소나의 후보 원화는 크기·구도가 P1과 다르다(결정문 §8.2 생성 규칙은 그대로
따르되, 캔버스에 맞추는 과정이 필요하다). 아래 두 도구가 "사람이 고른 후보 원화 +
codex 편집본 2장"을 파이프라인이 기대하는 base/base-front.png·
base/base-faceless.png·raw/body.png(모두 캔버스 1005x1566)로 만든다. 순서대로
쓴다:
-
register_edit.py— codex가 만든 얼굴 없는 기본형·body 편집본을 ECC 아핀 정렬(cv2.findTransformECC,MOTION_AFFINE)로 후보 원화(ref) 좌표계에 맞춘다. edited가 ref와 크기가 달라도 된다(먼저 ref 크기로 리사이즈한다).<venv>/python.exe register_edit.py <ref.png> <faceless-edit.png> <faceless-aligned.png> \ --mode faceless --report <faceless-report.json> <venv>/python.exe register_edit.py <ref.png> <body-edit.png> <body-aligned.png> \ --mode body --report <body-report.json>콘솔·리포트 JSON에 정렬 품질 지표(변환 배율·회전·이동, 마스크 안 Canny 에지 챔퍼 거리 중앙값·p90, faceless는 얼굴 윤곽 띠 챔퍼와 눈·눈썹·입 상자 잉크 비율)를 낸다 — 판정은 하지 않는다(수용 기준은 출력만, 사람/오케스트레이터가 본다).
-
normalize_base.py— 위 세 장(원래 ref, 정렬된 faceless, 정렬된 body)에 같은 아핀 변환 하나(등방 배율 + 평행이동, 회전 없음)를 적용해 캔버스로 옮긴다. 배율· 이동은 P1base-front.png의 눈 중심·눈 사이 거리·눈-턱 거리 비율에 맞춘다 (P1과 머리 크기 비율이 같아지도록).<venv>/python.exe normalize_base.py <persona-dir> \ --front <ref.png> --faceless <faceless-aligned.png> --body <body-aligned.png>출력:
<persona-dir>/base/base-front.png·base/base-faceless.png(1005x1566 RGB, 위아래 빈 곳은 원본 가장자리 띠 반사 패딩)·raw/body.png(1005x1566, 빈 곳은#00ff00)·base/normalize.json(배율·오프셋·덮인 범위·입력 파일 sha256).
이 둘은 persona.json이 아직 없어도 돈다(두 스크립트 다 페르소나 폴더 경로만
받고 load_persona_config에 의존하지 않는다) — persona.json은 이 산출물을 보고
그다음에 쓴다(아래 "새 페르소나를 추가할 때" 3번).
모델 파일(저장소에 없음)
scripts/_models/에 MediaPipe 모델을 받아 둔다(모든 페르소나가 공유, .gitignore의
docs/avatar-art/*/scripts/_models/ 패턴에 그대로 맞는다).
face_landmarker.task: https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.taskselfie_multiclass_256x256.tflite: https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_multiclass_256x256/float32/latest/selfie_multiclass_256x256.tflite
python 환경은 numpy·Pillow·scipy·opencv·mediapipe가 필요하다.
새 페르소나를 추가할 때 사람이 해야 하는 일
이 파이프라인은 자동으로 원화를 만들지 않는다. 사람(또는 다른 워커)이 먼저 준비해야 하는 것:
- 원화 2장:
<persona-dir>/base/base-front.png(정면 기본형)과base-faceless.png(같은 그림에서 눈·눈썹·입만 지운 것). 결정문 §8.2 생성 규칙을 따른다. 후보 원화 크기·구도가 P1과 다르면(캐스트 확장이 보통 그렇다) 바로 두지 말고 위 "캐스트 확장 0단계" 도구(register_edit.py→normalize_base.py)로 캔버스에 맞춰 만든다. <persona-dir>/raw/body.png: body 레이어가 head_mask로 가려지는 목 상단 영역을 메울 재생성 참조 편집본(크로마키 초록 배경, base-front와 같은 정렬). 역시 캐스트 확장이면 위 도구가 만든다.<persona-dir>/persona.json: 위 스키마대로 작성한다. 특히styleFrame·paletteSamples·pivots는 원화를 눈으로 보고 정해야 한다(자동 유도 불가).- (선택)
<persona-dir>/motif/: 모티프 스프라이트는 이 라운드 범위 밖이다 — 오케스트레이터가 따로 설계한다. 없으면export_rig.py가 모티프 없는 리그를 만든다(rig.motif 생략). scripts/_models/에 모델 파일이 없으면 받아 둔다(위 링크, 한 번만).
그 다음 run_pipeline.py <persona-dir>를 실행하고, 출력된 검사 수치(halo%, 평균절대차
등 — 각 단계 스크립트가 콘솔에 찍고 manifest.json에도 남긴다)를 기준치와 비교해 판정한다.
QA 캡처(qa/capture_cast_sheet.mjs)
리그가 게시된(run_pipeline.py가 export_rig.py까지 끝낸) 페르소나를 Lab
(/dev/avatar-lab)에서 조작해 캐스트 시트 5장을 만든다.
node qa/capture_cast_sheet.mjs <CODE> <outDir> [--base http://localhost:5173]
<CODE>:P1처럼 대문자 페르소나 코드.apps/web/src/components/avatar/v3/rigs/<code>Rig.ts의<CODE>_LINOCUT_RIGexport와docs/avatar-art/<code>-linocut/base/base-front.png를 읽는다.- Lab에
?persona=<CODE>쿼리(v3/rigs/index.tslabLinocutRigs()+AvatarLab.tsx?persona=지원, 세션 화면과 무관)로 들어가 그 페르소나를 바로 고른다. --base가 가리키는 dev server가 이미 떠 있어야 한다(이 스크립트는 서버를 띄우거나 끄지 않는다). 기본값http://localhost:5173.
산출(<outDir>에 JPG, 모두 긴 변 ≤1000px — 개별 캡처 PNG는 <outDir>/frames/):
sheet-expr-bust.jpg/sheet-expr-face.jpg: 16표정(강도 1) × bust/face 크롭, 4×4 격자.sheet-viseme.jpg: 비짐 9종(X·M·A·EO·O·U·EU·I·E), 입 주변 확대, 3×3 격자. Lab에 채널을 직접 주입하는 고정 기능이 없어(엔진 값을 바꾸지 않는 이 패킷 범위에서는 추가하지 않는다) 비짐 9종이 모두 나오는 문장을 재생하며 50ms 간격으로 (시각·비짐·스크린샷)을 한 패스에 모아 두고, 각 비짐이 가장 오래 유지된 구간의 중앙 표본을 그 비짐의 대표 프레임으로 쓴다(스캔과 재생을 두 패스로 나눠 같은 시각을 다시 재생하면 클릭~엔진 시계 리셋 사이 수십ms급 비결정성 때문에 스캔 때와 다른 비짐이 잡힐 수 있음을 실측으로 확인했다 — 항상 한 패스로 찍는다).<outDir>/viseme-timeline.json에 전체 표본을 남긴다.neutral-vs-art.jpg: neutral 렌더(portrait 크롭)와base/base-front.png를 나란히 둔 것 — 리그 피벗·배율이 원화와 맞는지 한눈에 비교한다.face-art-vs-render.jpg: 고해상도 얼굴 대조(2차 보정 T4b).rig.crops.face영역을 원화는 500px, 렌더(neutral·grief·joy·angry)는 760px로 키워 2줄 3칸으로 나란히 두고 최종 긴 변 ≤1000px로 다시 줄인다 — 눈·눈썹처럼 작은 차이가 bust 전체 비교에서는 묻히는 부위를 본다.
깜빡임/유휴 시선 흔들림 회피(screenshotEyeStable, 2차 보정 T4a): neutral-vs-art.jpg·
face-art-vs-render.jpg의 정적 비교 스크린샷은 Reduced motion을 다시 켠 뒤(viseme
구간에서 꺼 둔 채로 남는다 — 유휴층 breath·blink·시선 흔들림이 채널에 섞여 든다)
700ms 간격 3연사 중 eye-left outline(흰자 바탕) bbox 높이가 가장 큰 장을 쓴다 —
neutral-vs-art가 깜빡임 중에 찍힌 사례(P2)와, 원화 대비 렌더 홍채 아래 흰 띠가
유휴 시선 흔들림(gazeY) 중에만 나타나는 것을 실측으로 확인했다(2026-10-01).