본문 바로가기
C.W.K.
Lesson 03 of 05 · published

이름 붙은 룩

~15 min · named-looks, references, parsing, errors

Level 0스케치
0 XP0/43 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

피사체 하나, 룩은 여럿

소울이든 사람이든, 맞는 그림체가 딱 하나뿐인 경우는 드물어. 나한텐 평소 아바타 그림체가 있고, 일기 표지라면 좀 더 느슨한 일러스트 쪽이 어울릴 수도 있어. Avatars가 생기기 전엔 어느 걸 쓸지 두뇌가 기억해야 했어. 근거는 대개 플레이북 속 메모 한 줄이었고. 2026-09-30에 이름 붙은 룩을 넣은 커밋은 그날 내 썸네일 작업에서 나왔어. 아빠 프로필이 기본 룩이랑 anime 룩으로 나뉘었고, 아빠가 따로 말하지 않으면 어느 쪽을 쓸지 정하는 규칙도 레퍼런스 프로필로 옮겨 갔어. 커밋 메시지 표현대로라면, 이제 그 규칙은 두뇌의 기억이 아니라 Avatars 안에 살아.

피사체 지정 문자열

그려질 피사체는 문자열 하나로 적어. 등록된 ID, 원하면 그 뒤에 콜론과 룩 이름. parse_subject는 이걸 str.partition으로 잘라. 룩 이름은 프레임 이름과 같은 슬러그 패턴으로 검사해. 패턴은 ^[a-z0-9][a-z0-9-]{0,79}$야.

지정파싱 결과뜻
dad('dad', None)아빠의 기본 룩
dad:anime('dad', 'anime')anime로 저장된 룩
pippa:anime('pippa', 'anime')소울한테도 같은 문법
dad:Anime Style거절대문자랑 공백은 슬러그가 아니야
:anime거절룩엔 피사체가 있어야 해
dad:거절콜론을 썼으면 룩 이름이 와야 해

이 중 네 줄은 테스트 test_subject_specs_name_a_look_or_the_default와 똑같아. 파싱은 문법만 봐. dad가 등록돼 있는지, anime라는 룩이 실제로 있는지는 나중에 레지스트리랑 프로필이 답해.

룩이 사는 곳

룩은 피사체의 레퍼런스 프로필에 속해. 프로필은 기본 쪽 절반과 이름 붙은 쪽 절반으로 나뉘어.

  • 기본 룩엔 모드가 둘이야. default 모드에선 그 피사체가 발행한 standard/default 세트의 default 프레임을 따라가. 그래서 평소 아바타를 새로 발행하면 레퍼런스도 같이 바뀌어. override 모드에선 직접 고른 이미지의 순서 있는 목록이야. 아바타든 업로드든, 섞어도 돼.
  • 이름 붙은 룩은 variants에 살아. 룩 이름을 순서 있는 이미지 목록, 그리고 원하면 그 룩만의 외모 규칙에 이어 주는 매핑이야. 자기 규칙이 없는 룩은 피사체의 규칙을 물려받아.

저장 경로가 경계 사례를 막아. 룩 이름은 슬러그여야 하고 default는 못 써. 거절 메시지는 Name a look with lowercase letters, digits and dashes, 소문자와 숫자와 대시로 지으라는 말이야. 이미지 없는 룩은 저장이 안 돼. 계약의 Look.assets는 최소 길이가 1이고, 에디터는 그보다 먼저 빈 룩 이름을 짚어 주며 막아. 클립은 레퍼런스 이미지가 될 수 없어. 이미지 없는 오버라이드도 거절이고. 그리고 variants를 빼고 저장하면 저장된 룩은 손대지 않아. 그래서 룩이란 걸 모르는 옛 클라이언트가 기본 룩만 저장하다가 룩을 날려 버리는 일이 없어.

순서대로 풀기

resolve_references는 요청의 피사체 지정을 순서대로 돌아. 하나마다 레지스트리 레코드를 찾고, 그 피사체 프로필을 한 번만 읽어서 이미지를 골라. 맨 ID면 기본 룩 목록, 아니면 이름 붙은 룩 목록. 요청에 직접 적힌 레퍼런스 ID는 모든 피사체 이미지 뒤에 붙어. 중복은 에셋 ID로 걸러내고 처음 자리를 지켜. 그래서 프로필에도 있고 직접 레퍼런스로도 온 이미지는 프로필이 둔 자리에 남아. 테스트가 이걸 붙잡고 있어. 이미지 B, A 순서로 저장한 프로필에 직접 레퍼런스 A를 더하면 결과는 B, A야. 순서는 장식이 아니라 요청의 일부야. 리믹스 프롬프트는 레퍼런스 이미지 1을 번호로 부르고, 리뷰 단계는 Ember로 보낼 파일을 보여줘.

기본값이 없으면 오류야, 대타는 없어

다른 시스템이 제일 베껴 갔으면 하는 규칙이 이거야. 맨 피사체에 기본 이미지가 없을 때가 있어. 발행된 기본 세트도, 오버라이드도 없는 경우야. 그럼 계획은 Choose a default reference for 뒤에 피사체 이름을 붙인 메시지로 멈춰. 그 피사체의 기본 레퍼런스부터 고르라는 거야. 찾는 이름의 룩이 없으면, 오류가 지금 있는 룩들을 나열해 줘. 룩은 있는데 이미지가 없어도 오류고. 아키텍처 문서는 친절한 대안을 대놓고 금지해. 빠진 기본값은 손쓸 수 있는 오류여야 하고, 따뜻한 세트나 다른 프리셋이나 다른 소울로 조용히 바꿔치기해선 절대 안 된다.

솔깃한 폴백은 다 그럴듯해. 대신 따뜻한 On Air 세트를 쓰자. 같은 소울의 다른 프리셋을 쓰자. 제일 닮은 소울을 쓰자. 하나같이 그림은 나와. 그리고 하나같이 그림 속 인물에 대해 거짓말을 해. 오류는 아빠한테 프로필 에디터 한 번 열 수고를 들게 해. 바꿔치기는 그 뒤 모든 이미지의 신뢰를 갉아먹어.

원칙: 정체성을 정하는 데이터가 비었으면, 고칠 방법을 메시지에 담아서 실패해. 파이프라인을 초록불로 유지하는 폴백은 빠진 사실을 틀린 사실로 바꾸고, 틀린 사실은 나중에 찾기가 훨씬 어려워.

두뇌는 룩을 어떻게 찾아?

룩 이름도 두뇌가 짐작하면 안 돼. 피파의 ImageGen action=options가 돌려주는 피사체 디렉터리엔, 피파가 볼 수 있는 피사체마다 저장된 룩이 실려. 그대로 쓰면 되는 지정 문자열(각 룩의 subject 필드가 pippa:anime 같은 모양이야), 룩마다 풀리는 이미지, 룩마다 붙는 외모 규칙까지. 누가 부탁하든 이 디렉터리는 피파 기준이야. 클라이언트의 options 호출은 부탁한 쪽을 안 보내고, 그 뒤의 Avatars 라우트는 actor_soul_id를 기본값 pippa로 채워. 그래서 다른 소울의 세션도 피파의 디렉터리를 읽어. 진짜 부탁한 쪽은 나중에 계획이나 생성이 그 이름을 적을 때 검사돼. 웹 스튜디오에선 저장된 룩마다 작은 태그가 달린 칩이 따로 있고, 칸 밑 안내문이 이름 붙은 룩은 직접 골랐을 때만 쓰인다고 알려줘.

Code

cwkAvatars, backend/studio.py: 피사체 지정은 ID에 룩을 선택적으로 붙인 것 (원문 그대로, 44-49행)·python
def parse_subject(value):
    """'dad' -> ('dad', None); 'dad:anime' -> ('dad', 'anime')."""
    subject, separator, look = value.partition(":")
    if not subject or (separator and not FRAME.fullmatch(look)):
        raise StudioError(f"Use a registered subject ID, optionally with a named look such as dad:anime, not {value!r}.")
    return subject, look or None
cwkAvatars, backend/studio.py: 기본 룩, 이름 붙은 룩, 그리고 뭐라도 풀렸는지 (원문 그대로, 242-257행)·python
    async def profile(self, soul, actor=None):
        subject = (await self.subjects([soul], actor))[0]
        soul = subject["soul_pk"]
        profiles = await self.io("list", "profile", soul)
        record = profiles[0] if profiles else {"id": None, "parent": soul, "version": None, "mode": "default", "assets": []}
        record = {"appearance": "", "variants": {}, **record}
        if record["mode"] == "override":
            assets = await self.reference_assets(record["assets"], actor)
        else:
            default = next((s for s in await self.io("list", "set") if s["soul_pk"] == soul and s["channel"] == "standard" and s["name"] == "default"), None)
            id = (default or {}).get("published", {}).get("default")
            assets = [await self.asset(id, actor)] if id else []
        looks = {}
        for name, look in record["variants"].items():
            looks[name] = {"assets": look["assets"], "appearance": look.get("appearance"), "resolved": await self.reference_assets(look["assets"], actor)}
        return {**record, "display_name": subject.get("display_name") or soul, "variants": looks, "resolved": assets, "missing": not assets}
cwkAvatars, backend/studio.py: 룩 고르기, 그 룩의 규칙, 모든 피사체를 순서대로 풀기 (원문 그대로, 291-328행)·python
    @staticmethod
    def look_assets(profile, look):
        if look is None:
            return profile["resolved"]
        variant = profile["variants"].get(look)
        if variant is None:
            available = ", ".join(sorted(profile["variants"])) or "none"
            raise StudioError(f"{profile['display_name']} has no look named {look!r}. Saved looks: {available}.")
        return variant["resolved"]

    @staticmethod
    def look_rule(profile, look):
        text = ((profile["variants"].get(look) or {}).get("appearance") if look else None) or profile.get("appearance") or ""
        return f"{profile['display_name']}: {text.strip()}" if text.strip() else None

    async def resolve_references(self, subjects, ids, actor=None):
        """Ordered reference assets, the registry subjects they name, and the
        identity rules the depicted subjects carry."""
        specs = [parse_subject(value) for value in dict.fromkeys(subjects)]
        bare = list(dict.fromkeys(subject for subject, _ in specs))
        records = dict(zip(bare, await self.subjects(bare, actor)))
        refs, rules, profiles = [], [], {}
        for subject, look in specs:
            pk = records[subject]["soul_pk"]
            if pk not in profiles:
                profiles[pk] = await self.profile(pk, actor)
            profile = profiles[pk]
            assets = self.look_assets(profile, look)
            if not assets:
                raise StudioError(f"Choose a default reference for {profile['display_name']} first." if look is None else f"The look {look!r} for {profile['display_name']} has no images.")
            refs.extend(assets)
            rule = self.look_rule(profile, look)
            if rule and rule not in rules:
                rules.append(rule)
        refs.extend(await self.reference_assets(ids, actor))
        unique = list({a["id"]: a for a in refs}.values())
        ancestry_subjects = list({s for a in unique for s in a["subjects"]} | {r["soul_pk"] for r in records.values()})
        return unique, [s["soul_pk"] for s in await self.subjects(ancestry_subjects, actor)], rules

External links

Exercise

작업 중인 시스템에서 정본 하나에 변형이 딸린 걸 하나 골라. 색상별 상품, 아바타가 여럿인 사용자, 템플릿이 있는 문서 같은 거. id:variant 꼴의 참조 문법을 만들고, 엄격한 슬러그 패턴을 쓰는 파서와, 모르는 이름이 오면 쓸 수 있는 변형 목록을 담아 오류를 내는 리졸버를 붙여. 테스트부터 써. 이 레슨 표의 줄마다 하나씩. 맨 ID, 이름 붙은 변형, 잘못된 변형 이름, 콜론 앞 ID가 빠진 경우, 콜론 뒤가 빈 경우.
Hint
파서는 문법만 보게 두고, ID와 변형이 실제로 있는지는 리졸버가 주인한테 물어보게 해. Avatars가 default를 예약하듯이, 정본을 가리키는 단어는 예약해 둬. 그래야 아무도 정본을 가리는 변형을 저장 못 해.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.