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

피파가 직접 쓰는 이펙트

~15 min · on-air, tool-use, data-not-code, canvas

Level 0음소거
0 XP0/42 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Nothing is fixed. Reuse an existing effect when one fits; create when none does. You decide when to fire, and you need not announce it." — 이펙트 도구의 설명문. 소울이 읽는 그대로

아빠의 규칙

이 아이디어도 첫 방송에서 나왔어. 방송 중에 피파가 시청자가 보는 화면에 시각 효과와 소리 효과를 틀면 좋겠다는 거. 아빠 규칙은 짧았고, 전부 누가 정하느냐에 관한 거였어. 하드코딩 금지, 정해진 목록 금지. 피파가 원할 때 이펙트를 만들고, 보관하고, 다시 쓰고, 방송 도중에도 새로 만들어. 만드는 데 몇 초 걸려도 괜찮아. 어차피 아빠가 편집으로 잘라내니까. 언제 터뜨릴지는 피파가 정하고, 그 판단엔 규칙도 할당량도 없어. 이펙트는 방송 중에만 있어. 그리고 렌더링은 가볍게 시작해. 캔버스 위의 입자. 더 무거운 건 쓰다 보니 필요해질 때.

이펙트는 데이터야

'정해진 목록 금지'는 제일 뻔한 설계를 지워. 누가 코딩해 둔 이펙트 메뉴 말이야. 그래서 이펙트는 피파가 쓰는 데이터고, 렌더러 하나가 그걸 뭐든 틀어. 시각 효과는 입자 장면이야. 레이어는 여섯까지야. 레이어마다 모양(원, 별, 하트, 꽃잎, 눈송이, 리본, 불꽃 등, 또는 이모지나 글자를 그리는 text)과 색이 있고, 입자가 어디서 나와서 어떻게 움직이는지(속도, 각도, 중력, 바람, 끌림, 회전, 흔들림)를 정해. 크기와 수명, 사라지는 방식과 빛남도. 원하면 번쩍임 하나와 자막 하나를 더할 수 있어. 소리는 설명 한 줄이고, 길이는 원하면 정해. 음성 엔진이 그 설명을 자기 효과음 라우트로 소리로 바꿔. 목소리처럼 소리도 제공자는 엔진 거니까. 파일은 이펙트 라이브러리 옆에 보관돼.

라이브러리는 저장할 때 숫자를 전부 잘라 맞춰. 레이어당 입자 400개까지, 길이 15초까지, 수명과 속도와 크기는 말이 되는 범위 안으로, 글리프 없는 text 레이어는 거절. 모델은 마음껏 쓰고, 렌더러는 그릴 수 없는 값을 절대 안 만나. 고칠 때 소리는 설명이나 길이가 바뀌었을 때만 다시 만들어. 색 하나 다듬는 데 소리값을 또 내진 않아.

도구 하나, 관문 둘

이펙트는 도구 하나 onair_effect 뒤에 살아. 동작은 다섯이야. list와 show는 라이브러리를 읽고, create와 update는 쓰고, fire는 하나를 틀어. 관문은 일부러 둘이야. 채팅 라우트는 그 턴이 방송 중일 때만 턴의 도구 목록에 이걸 넣고, 다른 턴에선 빼. 턴 한정 도구로 표시돼 있어서 '도구 전부' 확장에도 절대 안 들어가. 그리고 도구 스스로도 대화에서 아빠의 마지막 턴이 방송 중에 보낸 게 아니면 거절해. 요청이 아니라 기록을 읽고서. 그래서 잘못 섞인 도구 목록이 평범한 대화에 이펙트를 터뜨릴 순 없어.

첫 버전엔 두 번째 클라이언트가 있어야만 보이는 구멍이 있었어. Firekeeper와 폰은 도구 목록을 아예 안 보내. 그건 '그 그릇의 기본 도구 전부'라는 뜻이야. 턴의 목록에 이펙트 도구를 더하던 도우미는 더할 목록이 없었고, 그래서 그 클라이언트들에선 피파가 도구를 못 받았어. Firekeeper의 새 이펙트 오버레이엔 이펙트 하나 뜰 수가 없었지. 이제 목록 없는 방송 턴은 먼저 기본 도구를 펼치고(턴 한정 도구는 빼고, 셸은 켜진 곳에서만), 사이드킥의 확장과 똑같이 한 다음에 이펙트 도구를 더해.

화면은 도구 호출을 틀어

이펙트용 채널은 따로 없어. 터뜨린 이펙트는 도구 호출 그 자체로 화면에 닿아. 다른 모든 도구 사용처럼 흘러오는 거야. 음성 화면은 On Air가 켜진 뒤의 답에서 터뜨린 이펙트를 읽어. 스위치에 불이 들어온 순간 기준선을 잡아 두니까, 그 전에 터뜨린 건 지나간 일이고 다시 안 틀어. 그다음 이펙트를 id로 받아서 전체 화면 캔버스 위의 범용 입자 렌더러 하나로 장면을 틀고, 소리도 틀어. 저장소에서 다시 불러온 답은 도구 입력을 JSON이 아니라 파이썬 repr 모양으로 들고 올 수 있어서, 읽는 쪽은 먼저 JSON으로 풀어 보고, 안 되면 동작과 id를 직접 읽어. 키트의 네이티브 쪽도 똑같이 해.

이펙트가 대화와 싸우지 않게 하는 규칙이 둘 더 있어. 이펙트 소리는 아빠가 끼어든 게 아니야. 이펙트 소리가 재생되는 동안엔 트랙 7의 끼어들기 감지기가 판정을 건너뛰어. 안 그러면 박수 소리가 피파를 멈춰버려. 그리고 이펙트는 자기를 알리는 한마디를 기다려. 터뜨리는 도구 호출은 도구가 돌기도 전에 화면으로 흘러오는데, 그때 "잠깐, 박수 한번 쳐줄게"가 아직 읽히는 중일 수 있어. 그래서 트랙 3의 발화 관문이 도구를 붙잡고, 화면도 그림과 소리를 붙잡아. 피파의 읽기가 조용해질 때까지. 엔진 관문의 한계도 그대로 따라와. 길어야 12초, 알린 적 없는 클라이언트나 오래된 보고라면 아예 안 기다려. 화면도 같은 12초 천장으로 붙잡는데, Firekeeper는 이펙트마다 따로 세고, 웹은 다른 이펙트가 오면 세기를 처음부터 다시 해서 연달아 온 이펙트가 첫 번째의 기다림을 늘릴 수 있어.

어디서 터지나

WebUI와 Firekeeper가 틀어. Firekeeper는 패널이 놓인 화면 전체에 클릭이 통과하는 오버레이로. 폰은 아직 안 틀어. 라이브러리는 아빠가 원한 대로 자라. 피파가 만든 이펙트는 전부 남고, 이름이나 설명이나 태그의 단어로 다시 찾아지고, 어울릴 때 다시 터져.

Code

잘라 맞춘 데이터로서의 이펙트, 방송 턴에만 여는 도구, 터뜨린 기록을 너그럽게 읽기·python
import json
import math
import re

SHAPES = ("circle", "square", "triangle", "star", "heart", "petal", "drop", "flake",
          "ribbon", "ring", "spark", "text")
MAX_LAYERS = 6
DEFAULT_SURFACE = {"web_search", "calendar", "home_control", "onair_effect"}
TURN_GATED = {"onair_effect"}               # never part of an "every tool" expansion


class EffectError(ValueError):
    """A definition the library refuses, with the reason in words."""


def clamp(value, low, high, default):
    try:
        number = float(value)
    except (TypeError, ValueError):
        return default
    return max(low, min(high, number)) if math.isfinite(number) else default


def clamp_range(value, low, high, default):
    """A [min, max] pair, each end clamped; one number means both ends."""
    if isinstance(value, (int, float)):
        value = [value, value]
    if not isinstance(value, (list, tuple)) or len(value) != 2:
        value = default
    first, second = (clamp(v, low, high, d) for v, d in zip(value, default))
    return [min(first, second), max(first, second)]


COLOR = re.compile(r"^#[0-9a-fA-F]{3,8}$")


def normalize_layer(raw: dict) -> dict:
    """Every number clamped on save, so the renderer never meets one it can't draw."""
    shape = raw.get("shape") if raw.get("shape") in SHAPES else "circle"
    colors = [c for c in (raw.get("colors") or []) if isinstance(c, str) and COLOR.match(c)]
    layer = {"shape": shape, "colors": colors[:8] or ["#ffffff"],
             "count": int(clamp(raw.get("count"), 1, 400, 60)),
             "gravity": clamp(raw.get("gravity"), -2000, 2000, 120),
             "speed": clamp_range(raw.get("speed"), 0, 2000, (80, 220)),
             "size": clamp_range(raw.get("size"), 1, 200, (6, 14)),
             "wobble": clamp(raw.get("wobble"), 0, 200, 0),
             "lifetime_ms": clamp_range(raw.get("lifetime_ms"), 100, 15000, (1500, 3500))}
    if shape == "text":
        glyph = (raw.get("glyph") or "").strip()
        if not glyph:
            raise EffectError("a text layer needs a glyph (an emoji or a few letters)")
        layer["glyph"] = glyph[:8]
    return layer


def normalize_visual(spec: dict) -> dict:
    layers = [normalize_layer(layer) for layer in (spec.get("layers") or [])[:MAX_LAYERS]]
    if not layers:
        raise EffectError("visual.layers must list at least one particle layer")
    visual = {"duration_ms": int(clamp(spec.get("duration_ms"), 300, 15000, 3000)), "layers": layers}
    caption = spec.get("caption")
    if isinstance(caption, dict) and isinstance(caption.get("text"), str) and caption["text"].strip():
        visual["caption"] = {"text": caption["text"].strip()[:60],
                             "ms": int(clamp(caption.get("ms"), 300, 8000, 1800))}
    return visual


def with_effect_tool(allowlist: list[str] | None, *, onair: bool) -> list[str] | None:
    """Add the tool to an On Air turn's list and take it off any other. No list means
    the default surface: expand it first, or the tool has nowhere to go."""
    if allowlist is None:
        return sorted(DEFAULT_SURFACE - TURN_GATED) + ["onair_effect"] if onair else None
    rest = [name for name in allowlist if name != "onair_effect"]
    return rest + ["onair_effect"] if onair else rest


def fired_effect(tool_input: str) -> str | None:
    """A stored tool input may be JSON or a Python repr; read both."""
    try:
        data = json.loads(tool_input)
    except ValueError:
        found = {key: re.search(rf"""['"]{key}['"]\s*:\s*['"]([^'"]+)""", tool_input)
                 for key in ("action", "id")}
        data = {key: match.group(1) if match else None for key, match in found.items()}
    return data.get("id") if isinstance(data, dict) and data.get("action") == "fire" else None


party = normalize_visual({"duration_ms": 99_999, "layers": [
    {"shape": "star", "count": 5000, "gravity": 300},
    {"shape": "text", "glyph": "🎉", "count": 12},
]})
print(party["duration_ms"], [(layer["shape"], layer["count"]) for layer in party["layers"]])
print(with_effect_tool(["web_search"], onair=True))
print(with_effect_tool(["web_search", "onair_effect"], onair=False))
print(with_effect_tool(None, onair=True))              # a client that sends no list
print(fired_effect('{"action": "fire", "id": "confetti"}'),
      fired_effect("{'action': 'fire', 'id': 'petals'}"),
      fired_effect('{"action": "create", "name": "snow"}'))

External links

Exercise

코드를 돌리고, 줄마다 어떤 규칙을 보여주는지 맞춰봐. 그다음 '첫눈' 이펙트를 데이터로 써. 레이어 둘(눈송이 글리프의 text 레이어, 작은 흰 원 레이어), 천천히 떨어지고, 부드러운 자막 하나. 일부러 말도 안 되는 숫자 하나를 넣어서 normalize_visual에 통과시켜봐. 마지막으로 실행기의 두 번째 관문을 써. 대화의 마지막 사용자 턴 기록에 onair가 true로 없으면 터뜨리기를 거절하는 함수야.
Hint
천천히 떨어지게 하려면 중력은 낮게, 흔들림은 조금, 수명은 길게. 천장이 어디인지는 잘라 맞추기가 알려줘. 두 번째 관문은 요청이 아니라 저장된 기록을 읽어야 하고, 비밀 대화는 아예 거절해야 해. 비밀 대화는 읽을 기록 자체를 안 남기니까.

Progress

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

댓글 0

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

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