~15 min · on-air, fullscreen, avatar-frames, determinism
Level 0음소거
0 XP0/42 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"Nod, then smile, then nod: a listener, not a metronome." — On Air 프레임 디렉터 안의 주석
이제 화면이 곧 세트야
아빠가 대화를 녹화하는 순간, 화면에 떠 있는 건 전부 영상에 들어가. 음성 화면은 원래 앞에 앉은 한 사람을 위해 설계됐어. 엄지나 포인터가 닿기 좋은 곳의 버튼들, 동그란 얼굴, 그 전체를 두른 브라우저. 그걸 찍으면 하나하나가 잘해야 잡음이고, 잘못하면 유출이야. 탭 제목이나 북마크 하나가 화면에 걸리는 식으로. 그래서 On Air를 켜면 화면 자체가 네 가지 바뀌어.
불 하나, 그리고 비켜서는 컨트롤
켜지면 얼굴 위에 빨간 ON AIR 불이 들어와. 방송 카메라의 탤리 라이트가 진행자에게 주는 신호랑 같아. 지금 생방이라는. 컨트롤(닫기, 첨부, On Air, Cut in, Captions, 언어 칩, 일시정지)은 마지막으로 포인터가 움직이거나 화면을 만진 지 3초 뒤에 스르르 사라지고, 포인터가 움직이거나 손가락이 닿는 순간 돌아와. 녹화엔 피파 얼굴과 카드만 남고, 아빠는 여전히 작은 움직임 하나면 모든 컨트롤에 닿아.
전체 화면, 그리고 자기가 연 것만 닫기
WebUI에선 On Air를 켜면 브라우저 전체 화면으로도 들어가. 탭, 주소창, Dock이 녹화에서 빠지게. 브라우저는 사용자의 동작 안에서만 전체 화면을 허락해서, 스위치를 누르는 그 클릭이 직접 요청해. 요청이 허락되는 다른 순간은 없거든. On Air를 끄거나 음성 화면을 닫으면 On Air가 들어간 전체 화면만 빠져나와. 도우미가 자기가 들어갔는지를 기억하고 있어서, 아빠가 그 전에 스스로 전체 화면으로 가 있었다면 그대로 둬. 브라우저가 거절하는 프레임 안의 사이드킥 패널은 그냥 그 모양 그대로 있어.
1024픽셀 이상의 넓은 화면에선 On Air가 배치도 바꿔. 얼굴과 공유 파일 무대가 나란히 서서 가로를 전부 써. 영상 속 아빠의 카메라 화면은 구석에 작게 들어가니까.
방송을 위한 얼굴
On Air가 켜진 동안 얼굴은 대화가 어떤 브레인, 어떤 아바타 프리셋을 쓰든 전용 세트 하나야. 세트는 소울의 아바타 아래 프레임을 모아 둔 폴더(onair/<set>/<frame>)고, 듣는 동그란 얼굴 대신 16:9 프레임으로 보여. 세트는 찾아서 알아내. 거기 있는 폴더 중 기본 프레임을 가진 건 전부 세트야. 그래서 새 계절은 코드 수정이 아니라 새 폴더 하나야. 어느 걸 쓸지는 Admin에서 고르고, 고른 게 없으면 첫 번째로 가. 방송용 폴더는 평소 프리셋 목록엔 절대 안 나와. 계절은 둘이야. 검은 블라우스에 난롯가 서재인 fallwinter, 밝은 창가에 하늘색 블라우스인 springsummer.
세트마다 프레임이 열다섯 장이야. 중립 기본 얼굴과 말할 때의 감정 여덟, 그리고 듣기용 여섯. listening, blink, nod, smile, laugh, thinking. 그날 오후에 만든 첫 두 세트는 모든 프레임을 그 세트의 중립 프레임에서 편집했어. 세트를 만들기 전에 방법부터 증명했지. 눈 밖에선 깜빡임 프레임과 원본의 차이가 압축 잡음뿐이었어. 저녁엔 더 엄격한 규칙이 그 자리를 차지했어. 피파가 피파처럼 보이게, 이제 프레임마다 아빠가 손으로 다듬은 평소 아바타의 같은 감정에서 따로따로 출발하고(반응 프레임은 제일 가까운 걸로), 계절 참고 이미지는 블라우스와 방만 줘. 트랙 5의 계보 규칙을 그림으로 옮긴 거야. 사본에서 말고, 아빠가 이미 고른 원본에서 뽑아. 지금 쓰는 두 세트는 후보 스무 개 중에 아빠가 고른 거고, 밀려난 프레임 서른 장은 뭐든 덮어쓰기 전에 해시 목록과 함께 아바타 폴더 밖에 백업했어.
슬라이드쇼가 아니라 디렉터
정지 프레임 열다섯 장은 관심이 옮겨가는 대로 누가 골라줘야만 살아 보여. 프레임 디렉터가 대화 상태를 한 번에 한 장씩 프레임으로 바꿔.
들을 때: 듣기가 시작되면 마지막 답의 감정을 1.2초 붙잡고, 그다음 listening. 3초에서 6.5초마다 한 번씩 깜빡이는데, 한 번에 0.14초, 70밀리초 만에 딱 바뀌어. 다른 변화는 전부 350밀리초짜리 디졸브야. 서서히 감기는 깜빡임은 한숨처럼 보이거든.
아빠 말이 자리 잡을 때: 아빠 말의 조각이 확정될 때마다 반응 하나. 끄덕임(0.9초)과 미소(1.6초)를 번갈아. 매번 끄덕이면 메트로놈이야.
답을 준비할 때:thinking.
말할 때: 답의 감정. 그 감정이 playful, happy, excited로 들어오면 1.4초짜리 laugh로 열어.
없는 프레임: 세트에 없는 프레임은 평소의 감정 규칙으로, 그다음엔 기본 프레임으로 물러나.
깜빡이는 시각은 Math.random이 아니라 시드를 준 작은 생성기에서 나와. 그래서 시드가 같으면 어느 화면에서든 깜빡임이 같고, 테스트가 그걸 못 박을 수 있어. 웹과 폰은 같은 숫자를 들고 같은 케이스를 통과했어. Firekeeper가 디렉터의 두 번째 네이티브 사용처가 되자 폰 쪽 절반은 공용 음성 키트로 올라갔고, 이제 Firekeeper 패널도 같은 세트를 같은 방식으로 틀어.
Code
시드 준 깜빡임, 반응, 물러나기를 갖춘 프레임 디렉터·typescript
// The On Air frame director, reduced to its rules: signals in, one frame out.
type Mode = 'listening' | 'waiting' | 'speaking' | 'held';
const T = {
hold: 1.2, blinkMin: 3, blinkMax: 6.5, blink: 0.14, nod: 0.9, smile: 1.6, laugh: 1.4,
snapMs: 70, fadeMs: 350, maxStep: 0.1, laughOn: ['playful', 'happy', 'excited'],
};
/** A linear congruential generator: the same seed gives the same blinks everywhere. */
const nextSeed = (seed: number) => (Math.imul(seed, 1664525) + 1013904223) >>> 0;
export class Director {
private t = 0;
private mode: Mode | null = null;
private since = 0;
private nextBlink: number;
private blinkUntil = -1;
private reaction: { frame: string; until: number } | null = null;
private reactions = 0;
private pieces: number | null = null;
private emotion: string | null = null;
private current = '';
constructor(private frames: Set<string>, private seed = 1) {
this.nextBlink = this.drawBlink();
}
private random() { this.seed = nextSeed(this.seed); return this.seed / 2 ** 32; }
private drawBlink() { return this.t + T.blinkMin + this.random() * (T.blinkMax - T.blinkMin); }
private has(frame: string | null): frame is string { return !!frame && this.frames.has(frame); }
step(mode: Mode, replyEmotion: string | null, heardPieces: number, dt: number) {
this.t += Math.max(0, Math.min(dt, T.maxStep)); // a hidden tab is not a jump
if (mode !== this.mode) { this.mode = mode; this.since = this.t; this.reaction = null; }
if (mode === 'listening' && this.pieces !== null && heardPieces > this.pieces) {
const frame = this.reactions++ % 2 === 0 ? 'nod' : 'smile';
if (this.has(frame)) this.reaction = { frame, until: this.t + (frame === 'nod' ? T.nod : T.smile) };
}
this.pieces = heardPieces;
if (mode === 'speaking' && replyEmotion && replyEmotion !== this.emotion
&& T.laughOn.includes(replyEmotion) && this.has('laugh')) {
this.reaction = { frame: 'laugh', until: this.t + T.laugh };
}
this.emotion = replyEmotion;
if (this.reaction && this.t >= this.reaction.until) this.reaction = null;
const settled = this.t - this.since >= T.hold;
let frame: string;
if (this.reaction) frame = this.reaction.frame;
else if (mode === 'waiting' && this.has('thinking')) frame = 'thinking';
else if (mode === 'listening' && settled && this.has('listening')) frame = 'listening';
else {
const wanted = mode === 'waiting' || (mode === 'listening' && settled) ? 'warm' : replyEmotion ?? 'warm';
frame = this.has(wanted) ? wanted : 'default'; // a frame the set lacks falls back
}
if (frame === 'listening' && this.has('blink')) {
if (this.t >= this.nextBlink) { this.blinkUntil = this.t + T.blink; this.nextBlink = this.drawBlink(); }
if (this.t < this.blinkUntil) frame = 'blink';
}
const fadeMs = frame === 'blink' || this.current === 'blink' ? T.snapMs : T.fadeMs;
this.current = frame;
return { frame, fadeMs };
}
}
const set = new Set(['default', 'warm', 'happy', 'playful', 'listening', 'blink',
'nod', 'smile', 'laugh', 'thinking']);
const director = new Director(set, 42);
let pieces = 0;
let shown = '';
for (let i = 0; i <= 200; i += 1) { // 20 seconds at 10 frames a second
const t = i / 10;
const mode: Mode = t < 12 ? 'listening' : t < 14 ? 'waiting' : 'speaking';
if (i === 50 || i === 80) pieces += 1; // two pieces of his words land
const out = director.step(mode, t < 14 ? 'happy' : 'playful', pieces, 0.1);
if (out.frame !== shown) {
console.log(`${t.toFixed(1).padStart(5)}s ${mode.padEnd(9)} -> ${out.frame} (${out.fadeMs} ms)`);
shown = out.frame;
}
}
npx tsx로 디렉터를 돌려서 타임라인을 읽어봐. 그다음 세트에서 'laugh'를 빼고 다시 돌려. 'listening'까지 빼면 물러난 자리에 뭐가 뜨는지 봐. 마지막으로 결정론을 쓸모 있게 만드는 테스트를 써. 같은 시드로 만든 디렉터 둘에 같은 신호를 먹이면 깜빡이는 시각이 정확히 같아야 하고, 시드가 다르면 달라야 해.
Hint
'laugh'가 없으면 장난스러운 답은 그냥 자기 감정으로 열리고, 'listening'이 없으면 듣는 얼굴은 감정 규칙의 warm으로 물러나. 테스트는 1분 동안 들으면서 프레임이 'blink'가 되는 시각을 모아서 두 목록을 비교해. 어디선가 Math.random을 썼다면 테스트가 바로 알려줘.
Progress
Progress is local-only — sign in to sync across devices.