| name | webtoon-panel-assembly |
| description | 생성된 컷 이미지와 shot_list의 텍스트 메타를 결합하여 마우스/터치 스크롤로 읽을 수 있는 단일 세로 스크롤 웹툰 HTML 페이지(index.html)를 만드는 스킬. 말풍선·내레이션·시스템 UI·글리치를 모두 HTML/CSS로 오버레이한다. panel-assembler 에이전트가 호출한다. |
Webtoon Panel Assembly — 세로 스크롤 HTML 페이지 조립
핵심 룰
- 모든 텍스트는 HTML/CSS 오버레이, 이미지에 박지 않는다 (수정·번역·접근성 모두 유리)
- 컷 간격 0px 기본, 침묵 컷 앞뒤만 4px
- 반응형: 모바일 100vw / 데스크탑 800px 가운데 정렬
- scroll_length를 CSS min-height vh로 매핑 — 작가의 스크롤 호흡 의도 보존
- 이미지는
loading="lazy" + decoding="async" — 첫 컷 빠르게, 나머지 점진 로드
- 6가지 말풍선 스타일 지원 — dialogue / thought / shout / whisper / narration / sfx (아래 CSS 클래스 표 참조)
6가지 말풍선 CSS 클래스 (필수 구현)
dialogue-writer가 출력한 text_overlay 안의 style 필드에 따라 다른 CSS 클래스로 렌더링한다.
| style | CSS 클래스 | 시각 |
|---|
dialogue | .bubble-dialogue | 흰 둥근 말풍선 + 검정 굵은 글씨, 꼬리 있음 |
thought | .bubble-thought | 구름 모양 옅은 말풍선 + 회색 이탤릭 (border-radius로 구름 흉내) |
shout | .bubble-shout | 가시 모양 빨강 말풍선 + 큰 진한 글씨 (clip-path로 가시) |
whisper | .bubble-whisper | 점선 테두리 작은 말풍선 + 흐린 글씨 |
narration | .narration-doh | 무말풍선 + 손글씨 회색, 컷 상/하단 가로 |
sfx | .sfx-text | 무말풍선 + 굵은 산세리프 + 외곽선, 컷에 자유 배치 |
필수 CSS 정의:
.bubble-dialogue {
position: absolute;
background: rgba(255,255,255,0.96);
color: #111;
border-radius: 18px;
padding: 10px 16px;
font-weight: 700;
font-size: 16px;
line-height: 1.35;
max-width: 60%;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
}
.bubble-dialogue::after {
content: '';
position: absolute;
bottom: -8px;
left: 24px;
width: 0; height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 10px solid rgba(255,255,255,0.96);
}
.bubble-thought {
position: absolute;
background: rgba(220,220,230,0.92);
color: #444;
font-style: italic;
border-radius: 30px;
padding: 10px 18px;
font-size: 14px;
max-width: 55%;
box-shadow: 0 0 0 4px rgba(220,220,230,0.92), 0 0 0 8px rgba(220,220,230,0.6), 0 0 0 12px rgba(220,220,230,0.3);
}
.bubble-shout {
position: absolute;
background: #ff3b3b;
color: #fff;
font-weight: 900;
font-size: 28px;
padding: 14px 22px;
text-align: center;
clip-path: polygon(
0% 20%, 8% 0%, 22% 12%, 38% 0%, 52% 15%, 68% 0%, 82% 14%, 100% 5%,
95% 30%, 100% 55%, 92% 75%, 100% 100%, 80% 88%, 65% 100%, 50% 90%, 35% 100%, 20% 88%, 5% 100%, 0% 70%, 8% 45%
);
text-shadow: 2px 2px 0 #800;
}
.bubble-whisper {
position: absolute;
background: rgba(40,40,50,0.7);
color: #aaa;
border: 1px dashed #555;
border-radius: 12px;
padding: 6px 10px;
font-size: 13px;
font-style: italic;
max-width: 45%;
}
.narration-doh {
position: absolute;
left: 24px;
right: 24px;
font-family: 'Nanum Pen Script', cursive;
color: var(--bone);
opacity: 0.88;
font-size: 22px;
line-height: 1.4;
text-shadow: 0 2px 6px rgba(0,0,0,0.85);
}
.sfx-text {
position: absolute;
font-family: 'Black Han Sans', sans-serif;
color: var(--bone);
font-size: 36px;
font-weight: 900;
transform: rotate(-3deg);
text-shadow:
3px 3px 0 var(--void),
-2px -2px 0 var(--void),
2px -2px 0 var(--void),
-2px 2px 0 var(--void);
letter-spacing: 0.05em;
pointer-events: none;
}
위치 클래스 (모든 말풍선 공통)
| 클래스 | 위치 |
|---|
.pos-top-left | top: 24px; left: 24px; |
.pos-top-right | top: 24px; right: 24px; |
.pos-bottom-left | bottom: 32px; left: 24px; |
.pos-bottom-right | bottom: 32px; right: 24px; |
.pos-center | top: 50%; left: 50%; transform: translate(-50%, -50%); |
화자 위치 추론
- 컷의
characters 배열에서 화자가 좌측이면 → .pos-bottom-left
- 우측이면 →
.pos-bottom-right
- 단독 등장이면 → 화자가 보이는 쪽
- 다인 대화는 2말풍선 좌우 분할
워크플로우
Step 1 — 입력 로드
_workspace/episodes/ep{NN}/shot_list.json
_workspace/episodes/ep{NN}/prompts.json (post_overlay_text 정본)
_workspace/episodes/ep{NN}/images/cut_*.png
_workspace/episodes/ep{NN}/images/cut_*.meta.json (GEN_FAILED 확인)
Step 2 — index.html 골격 생성
하나의 자급자족 HTML 파일. CSS는 인라인. 이미지는 상대 경로.
Step 3 — 컷 단위 섹션 렌더링
컷마다 <section class="cut">을 만들고 다음을 결합:
<img> 또는 placeholder
- 시그니처 효과 오버레이 (cyan_grid, error_glitch 등)
- 텍스트 오버레이 (내레이션, 시스템 UI, 대사, 효과음, 운영자 텍스트, 몬스터 감정)
Step 4 — 회차 인덱스 갱신
_workspace/episodes/INDEX.html에 이번 회차 항목 추가/갱신.
HTML 템플릿
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>{{TITLE}}</title>
<style>
:root {
--cyan: #00E5D1;
--void: #050608;
--magenta: #FF1C6B;
--bone: #F2EEE3;
--gold: #C9A24B;
--pink: #FFB3D1;
--grey-text: #6E6E72;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--void); color: var(--bone); -webkit-font-smoothing: antialiased; }
body { font-family: 'Pretendard', 'Noto Sans KR', system-ui, sans-serif; }
.webtoon { max-width: 800px; margin: 0 auto; }
.cut { position: relative; overflow: hidden; }
.cut img { display: block; width: 100%; height: auto; }
.cut.silence { background: var(--bone); margin: 4px 0; }
.cut.silence-void { background: var(--void); margin: 4px 0; display: flex; align-items: center; justify-content: center; }
.cut.silence-void::after { content: ''; width: 4px; height: 4px; background: var(--bone); border-radius: 50%; }
.system-ui-text {
position: absolute; top: 16px; left: 16px;
font-family: 'JetBrains Mono', 'Courier New', monospace;
color: var(--cyan); font-size: 14px;
text-shadow: 0 0 8px rgba(0,229,209,0.6);
letter-spacing: 0.05em;
}
.narration-doh {
position: absolute; left: 24px; right: 24px;
font-family: 'Nanum Pen Script', 'Gaegu', cursive;
color: var(--grey-text); font-size: 18px;
line-height: 1.4;
}
.narration-doh.top { top: 24px; } .narration-doh.bottom { bottom: 24px; }
.dialogue-bubble {
position: absolute;
background: rgba(255,255,255,0.95); color: #111;
border-radius: 12px; padding: 8px 14px;
font-weight: 700; font-size: 16px; max-width: 70%;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.onomatopoeia {
position: absolute;
font-family: 'Black Han Sans', sans-serif;
color: var(--bone); font-size: 32px; font-weight: 900;
transform: rotate(-3deg); text-shadow: 2px 2px 0 var(--void);
}
.operator-text {
position: absolute; left: 50%; top: 50%;
transform: translate(-50%, -50%);
font-family: 'Noto Serif KR', serif;
color: var(--bone); font-weight: 300; font-size: 15px;
letter-spacing: 0.05em;
}
.monster-emotion {
position: absolute;
font-family: 'Noto Serif KR', serif;
color: var(--void); font-weight: 300; font-size: 14px;
background: rgba(242,238,227,0.85); padding: 4px 10px;
}
.glitch-text {
position: absolute;
font-family: 'Courier New', monospace;
color: var(--magenta); font-size: 18px;
animation: glitch 0.3s steps(2) infinite;
transform: skewX(-5deg);
}
@keyframes glitch {
0% { transform: skewX(-5deg) translateX(0); }
50% { transform: skewX(5deg) translateX(-2px); }
100% { transform: skewX(-5deg) translateX(2px); }
}
.cyan-grid-overlay {
position: absolute; inset: 0;
background-image:
linear-gradient(to right, rgba(0,229,209,0.08) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0,229,209,0.08) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
.error-glitch-overlay {
position: absolute; inset: 0; pointer-events: none;
background:
linear-gradient(to bottom, transparent 30%, rgba(255,28,107,0.15) 31%, transparent 32%),
linear-gradient(to bottom, transparent 60%, rgba(255,28,107,0.1) 61%, transparent 62%);
mix-blend-mode: screen;
}
.gen-failed-placeholder {
width: 100%; aspect-ratio: 9/16;
background:
repeating-linear-gradient(45deg, #1a1a1a, #1a1a1a 10px, #2a2a2a 10px, #2a2a2a 20px);
display: flex; align-items: center; justify-content: center;
color: var(--magenta); font-size: 20px;
}
.ep-header, .ep-footer {
padding: 40px 20px; text-align: center; color: var(--bone);
}
.ep-header h1 { font-size: 28px; margin: 0; letter-spacing: 0.05em; }
.ep-header .sub { color: var(--grey-text); margin-top: 8px; font-size: 14px; }
.next-ep-link {
display: inline-block; margin-top: 20px;
padding: 12px 24px; border: 1px solid var(--cyan);
color: var(--cyan); text-decoration: none;
font-family: monospace; letter-spacing: 0.1em;
}
</style>
</head>
<body>
<main class="webtoon">
<header class="ep-header">
<h1>{{EPISODE_TITLE}}</h1>
<div class="sub">Ep.{{NN}} · {{ACT_LABEL}}</div>
</header>
{{#each cuts}}
<section class="cut {{cut_classes}}" data-cut-id="{{cut_id}}" style="min-height: {{scroll_vh}}vh;">
{{cut_body}}
</section>
{{/each}}
<footer class="ep-footer">
<a class="next-ep-link" href="../ep{{NEXT_NN}}/index.html">다음 화 →</a>
</footer>
</main>
</body>
</html>
컷 본문 렌더링 (의사 코드)
function render_cut(cut):
html = ''
if cut.shot_type == 'silence':
return '<!-- silence: no image -->'
if image_status == 'GEN_FAILED':
html += '<div class="gen-failed-placeholder">컷 {N} 생성 실패 — 재생성 대기</div>'
else:
html += '<img src="images/cut_{NN}.png" alt="{purpose}" loading="lazy" decoding="async">'
# 시그니처 오버레이
if 'cyan_grid' in cut.signatures_used:
html += '<div class="cyan-grid-overlay"></div>'
if cut.glitch:
html += '<div class="error-glitch-overlay"></div>'
# 텍스트 오버레이
if cut.text_overlay.system_ui_text:
html += f'<div class="system-ui-text">{cut.text_overlay.system_ui_text}</div>'
if cut.text_overlay.narration_doh_yun:
html += f'<div class="narration-doh bottom">{cut.text_overlay.narration_doh_yun}</div>'
for d in cut.text_overlay.dialogue or []:
pos = d.get('position', {'bottom': '20px', 'left': '20px'})
html += f'<div class="dialogue-bubble" style="...">{d.text}</div>'
for s in cut.text_overlay.onomatopoeia or []:
html += f'<div class="onomatopoeia">{s}</div>'
if cut.text_overlay.operator_text:
html += f'<div class="operator-text">{cut.text_overlay.operator_text}</div>'
return html
scroll_length → vh 매핑
| scroll_length | min-height vh |
|---|
| 1 | 60vh (정사각/침묵) |
| 2 | 110vh (와이드) |
| 3 | 170vh (큰 임팩트) |
| 4 | 230vh (시즌 단일 컷 최대) |
화면 한 번에 1.x 정도 들어가도록 의도 — 스크롤 호흡 보존.
INDEX.html (회차 색인)
시즌 1 30화 전체를 보여주는 색인. 회차 카드형 배치:
<a href="ep01/index.html" class="card">
<img src="ep01/images/cut_01.png" alt="">
<div>Ep.1 — F급은 미끼다</div>
</a>
체크리스트