#!/usr/bin/env python3
"""전체 파이프라인 분석 문서 빌더 v2 — 쉬운 설명 + 유형 색 구분 + 조건부 플로우차트 (실험 전용, 커밋 금지)"""
import json, html, os

BASE = os.path.dirname(os.path.abspath(__file__))
MAN = json.load(open(os.path.join(BASE, "..", "model_compare", "manifest_dump.json")))
TS = json.load(open(os.path.join(BASE, "text_samples.json")))
OUT = os.path.join(BASE, "..", "pipeline_full_doc.html")

ACTIVE = {m["id"]: m for m in MAN if m["lifecycle"] == "active"}
DEPR = [m for m in MAN if m["lifecycle"] != "active"]

def esc(s): return html.escape(str(s if s is not None else ""))

MODEL_LABEL = {"gpt": "GPT-5.6 Sol", "gpt-mini": "Gemini Flash", "gpt-terra": "GPT-5.6 Terra",
               "gemini-pro": "Gemini 3.1 Pro", "gemini-flash": "Gemini 3.1 Flash",
               "gemini-lite": "Gemini Flash-Lite", "gemini-image": "Gemini 3.1 Flash Image",
               "-": "코드", "mixed": "혼합"}

# 유형: llm=글 분석 LLM / gen=이미지 생성 모델 / vlm=VLM(이미지를 보고 판단) / code=코드
TYPE = {
"scene_save": ["code"],
"floor_plan_render": ["gen"], "outdoor_site_layout": ["llm","gen"],
"outdoor_place_canon": ["gen","vlm"], "ref_image_gen": ["gen"],
"composite_image_gen": ["gen"], "character_state_variant": ["gen"],
"background_render": ["gen"], "scene_image_pipeline": ["gen","vlm"],
"floor_plan_geometry_readback": ["vlm"], "floor_plan_semantic_readback": ["vlm"],
"zoom_continuity_anchor": ["code","vlm"],
}
TYPE_LABEL = {"llm": "글 LLM", "gen": "이미지 생성", "vlm": "VLM 판독", "code": "코드"}

# (쉬운 설명, 입력 → 출력)
DESC = {
"planning_doc_analysis": ("기획안 PDF를 미리 읽고 작품의 배경 설정·인물 정보를 파악해 둡니다. 뒤 단계들이 이 지식을 참고합니다.", "기획안 PDF → 기획 분석 메모"),
"text_cleanup": ("PDF에서 뽑아낸 시나리오 글에서 페이지 번호·머리글 같은 잡티를 지워 깨끗한 원고로 만듭니다.", "업로드 원문 → 깨끗한 전문"),
"scene_segmentation": ("긴 시나리오를 '씬(장면)' 단위로 자를 위치를 찾습니다.", "전문 → 씬 경계 목록"),
"episode_summary": ("에피소드 전체 줄거리를 한 문단으로 요약합니다. 뒤 단계들의 전체 맥락 역할.", "전문 → 줄거리 요약"),
"visual_world_rules": ("이 작품의 시각 규칙을 뽑아 둡니다 — 귀신이 눈에 보이는 존재인지, 시대·장소는 어디인지 등. 감독 계열 단계들이 이 규칙(director_notes)을 계속 참고합니다.", "전문 → 시각 규칙"),
"scene_save": ("잘린 씬들을 원문 그대로 DB에 저장합니다. LLM을 쓰지 않는 순수 저장 단계.", "씬 경계 → 씬 30개 저장"),
"entity_character_list": ("시나리오에 나오는 등장인물 이름 목록을 미리 뽑아 둡니다. 뒤의 비트/샷 추출이 이 명단을 보고 인물을 정확히 지칭합니다.", "씬 전문 → 인물 명단"),
"scene_summary": ("씬 하나하나를 짧게 요약합니다 (30씬 병렬).", "씬 원문 → 씬 요약"),
"beat_extract": ("각 씬을 '비트'(이야기가 한 번 움직이는 최소 단위)로 잘게 나눕니다.", "씬 원문 → 비트 목록"),
"shot_extract": ("비트마다 사진 한 장으로 찍을 만한 순간(샷)을 골라냅니다. 한 샷 = 한 순간·한 장소가 원칙.", "비트 → 샷 후보"),
"shot_validator": ("뽑힌 샷들이 규칙을 지켰는지 검사합니다 — 회상·몽타주가 섞이지 않았는지, 변신 전/후 인물이 섞이지 않았는지 등.", "샷 → 검증된 샷"),
"entity_all_character": ("전체 샷을 훑어 실제 그림에 등장할 인물의 최종 명단을 확정합니다.", "전체 샷 → 인물 최종 명단"),
"entity_extract_character": ("인물마다 생김새·변하지 않는 특징을 정리해 '인물 카드'(캐논)를 만듭니다. 변신하는 캐릭터는 형태별로 카드를 따로 만듭니다.", "명단 → 인물 카드"),
"entity_all_location": ("이야기에 나오는 장소 목록을 확정합니다.", "전체 샷 → 장소 명단"),
"entity_extract_location": ("장소마다 생김새 설명을 정리해 '장소 카드'를 만듭니다.", "명단 → 장소 카드"),
"entity_all_prop": ("중요한 소품(사진, 주사기 같은 물건) 목록을 확정합니다.", "전체 샷 → 소품 명단"),
"entity_extract_prop": ("소품마다 설명을 정리해 '소품 카드'를 만듭니다.", "명단 → 소품 카드"),
"entity_merge": ("같은 대상이 이름만 다르게 두 번 등록된 경우를 찾아 하나로 합칩니다.", "카드 전체 → 중복 정리"),
"entity_relation": ("카드끼리의 관계를 연결합니다 — 예: '이 검은 형상은 민숙의 변형이다'.", "카드 → 관계 연결"),
"entity_filter": ("3씬 이하로만 나오는 사소한 요소를 지울지 LLM에게 판단시킵니다.", "카드+등장 횟수 → 정리"),
"entity_detail": ("카드의 외형 설명을 더 자세하게 다듬습니다.", "카드 → 상세 카드"),
"entity_t2i": ("각 카드로 참조 사진을 만들 수 있게 그림 생성용 영어 문장(T2I 프롬프트)을 씁니다.", "카드 → 그림 주문서"),
"shot_selection": ("씬마다 실제로 그림으로 만들 샷을 최대 3개 고릅니다. 비슷한 그림이 중복되지 않게.", "샷 후보 → 선택 표시"),
"scene_director": ("'씬 감독' — 각 씬에 어떤 인물이 등장하는지 최종 확정합니다. 이 확정 데이터로 코드가 샷별 등장 목록(VE)을 자동으로 만듭니다.", "씬+카드 → 인물 배정"),
"shot_director": ("샷마다 카메라 촬영 기법을 2가지씩 정합니다 (→ 그림도 2가지 버전).", "선택 샷 → 촬영 기법 2종"),
"scene_camera_flow": ("씬 안에서 카메라가 어떻게 이어질지(멀리→가까이 등) 흐름을 설계합니다.", "선택 샷 → 카메라 흐름"),
"shot_dependency": ("이 샷이 앞의 어떤 샷과 같은 장소·인물인지 연결 관계를 찾습니다. 나중에 참조 사진으로 이어 붙일 근거.", "선택 샷 → 연결 그래프"),
"outlook_phase1": ("인물들이 입는 옷(아웃룩) 목록을 뽑습니다.", "전문+카드 → 옷 목록"),
"outlook_phase2": ("씬마다 누가 어떤 옷을 입고 있는지 짝을 맞춥니다.", "옷 목록 → 씬별 매핑"),
"outlook_phase3": ("비슷한 옷을 합치고 정리해 최종 '옷 카드'(O01…)를 확정합니다.", "매핑 → 옷 카드"),
"shot_staging": ("샷마다 인물이 어디에 서고 어떤 자세인지(블로킹)를 정합니다.", "선택 샷 → 배치 계획"),
"background_classify": ("장소마다 실내인지 야외인지 나눕니다. 이 결과로 배경 제작 방식이 갈립니다(실내=도면 방식 / 야외=항공뷰 방식).", "장소 카드 → 실내/야외 표"),
"background_master_plan": ("장소별로 배경 그림을 몇 장, 어떤 각도로 만들지 계획표(L03B01 같은 번호)를 짭니다.", "장소+샷 → 배경 계획표"),
"floor_plan_prompt": ("실내 장소의 평면도(도면)를 그리기 위한 주문서를 씁니다.", "실내 장소 → 도면 주문서"),
"shot_essence_extraction": ("(꺼진 기능) 샷의 핵심만 요약.", "샷 → 요약"),
"scene_consistency": ("씬들 사이에 모순이 없는지 최종 교차 점검합니다.", "전체 확정 데이터 → 점검 결과"),
"floor_plan_render": ("실내 평면도 이미지를 실제로 그립니다. 방·가구 위치에 알파벳 마커가 찍힌 도면.", "도면 주문서 → 도면 이미지"),
"floor_plan_light_sidecar": ("도면에 조명 정보(창·광원 위치)를 덧붙입니다.", "도면 → 조명 메모"),
"floor_plan_overlay_payload": ("도면 위 마커 라벨 표시용 데이터를 만듭니다.", "도면 → 마커 데이터"),
"base_location_dossier": ("장소에 대해 확정된 사실들을 한 문서로 모읍니다.", "장소+도면 → 장소 대장"),
"floor_plan_geometry_readback": ("그려진 도면을 VLM이 '눈으로 보고' 마커가 제대로 찍혔는지 되읽어 검사합니다. 마커가 겹치면 그 도면은 다시 그립니다(회차당 1~2장 발생하는 만성 이슈).", "도면 이미지 → 판독 결과"),
"floor_plan_semantic_readback": ("도면의 의미(어느 방이 어디인지)를 VLM이 되읽어 확인합니다.", "도면 이미지 → 의미 판독"),
"shot_projection_card": ("(조건부) 샷 카메라를 도면 위에 투영한 카드를 만듭니다.", "도면+샷 → 투영 카드"),
"bg_space_partition": ("(조건부) 큰 공간을 구역으로 나눕니다.", "도면 → 구역 나눔"),
"dwelling_zone_map": ("(조건부) 주거 공간의 구역 지도를 만듭니다.", "도면 → 구역 지도"),
"shot_aware_bg_render_plan": ("샷의 카메라 각도를 반영해 배경 그림을 어떻게 변형해 만들지 계획합니다.", "샷+도면 판독 → 렌더 계획"),
"background_prompt": ("배경 그림 주문서를 씁니다. '2026년 대한민국'을 명시해 배경이 외국풍이 되는 것을 막는 지점.", "배경 계획표 → 배경 주문서"),
"episode_reference_policy": ("그림 만들 때 어떤 참조 사진(배경/인물/직전 샷)을 붙일지 규칙을 정합니다.", "샷+카드 → 참조 규칙"),
"visual_continuity_anchor": ("(조건부) 샷 사이 시각 연속성을 잡아주는 기준점을 만듭니다.", "샷 → 기준점"),
"scene_detail": ("샷마다 최종 그림 주문서(스틸 T2I)를 씁니다. 등장 목록(VE)에 없는 인물을 쓰면 자동으로 걸러내고 다시 씁니다.", "샷+VE+옷 카드 → 스틸 주문서"),
"shot_dependency_t2i": ("앞 샷과의 연결 정보를 주문서 문구에 반영합니다.", "연결 그래프 → 보강 주문서"),
"t2i_review": ("주문서 전체를 한 번에 검토하고 규칙 위반을 고칩니다.", "전체 주문서 → 검수 결과"),
"zoom_continuity_anchor": ("(조건부) 같은 장소를 당겨 찍은 샷을 위해 기존 그림에서 잘라낸 확대 참조를 만듭니다.", "샷 쌍 → 확대 참조"),
"outdoor_site_layout": ("야외 장소를 위에서 내려다본 항공뷰 3종을 만듭니다 — ①마커 찍힌 기본 항공뷰 ②인물 위치 표시 ③카메라 위치 스케치. (오래 걸리는 단계, 90분+ 사례)", "야외 장소+샷 → 항공뷰 3종"),
"outdoor_place_spec": ("[꺼짐] 야외 장소의 구역·마커 명세서를 씁니다. 시나리오에 근거 있는 것만(evidence-bound).", "씬 근거 → 마커 명세"),
"outdoor_place_canon": ("[꺼짐] 야외 장소의 대표 실사 사진과 지도를 만듭니다 — 후보 3장을 생성하고 GPT/Gemini VLM이 점수를 매겨 뽑은 뒤, 결함을 고치고 지도로 재투영.", "명세 → 대표 실사+지도"),
"outdoor_shot_grounding": ("[꺼짐] 각 샷이 지도의 어느 지점에서, 어느 방향을 보고 찍는지 판정합니다.", "지도+샷 → 위치·카메라 판정"),
"world_guide": ("지금까지의 확정 내용을 모아 월드 가이드 문서를 만듭니다.", "전체 산출물 → 가이드 문서"),
"space_set_bg": ("(조건부) 공간 세트 배경.", "—"),
"ref_image_gen": ("카드들의 참조 사진을 실제로 그립니다 — 1차: 인물 얼굴(증명사진풍) → 2차: 옷(마네킹 착장) → 장소·소품 순.", "그림 주문서 → 참조 사진들"),
"composite_image_gen": ("인물 얼굴 + 옷을 합쳐 '이 옷을 입은 이 사람' 전신 사진을 만듭니다. 스틸을 그릴 때 실제로 첨부되는 인물 참조.", "얼굴+옷 → 합성 전신"),
"character_state_variant": ("특수 상태의 인물 사진을 만듭니다 — 예: 시신 상태. 기존 합성 사진을 변형(i2i).", "합성 사진+상태 → 상태 변형"),
"background_render": ("배경 그림을 실제로 그립니다. 실내는 도면을 참조로, 야외는 항공뷰를 참조로. 검열에 걸리면 문구를 순화해 1회 재시도.", "배경 주문서+도면/항공뷰 → 배경 그림"),
"scene_image_pipeline": ("최종 스틸을 그립니다. ①참조 꾸러미 조립(배경+직전 샷+구도 가이드+인물 합성) → ②주문서 영어 번역 → ③그림 생성(샷당 N장) → ④참조가 규칙대로 다 붙었는지 코드 검증(RCV) — 위반이면 재시도.", "주문서+참조 → 씬 스틸"),
"outlook_dedup": ("(요청 시) 옷 카드 중복 정리.", "옷 카드 → 병합"),
}

def tag_badges(sid):
    tags = TYPE.get(sid, ["llm"])
    return "".join(f'<span class="tg tg-{t}">{TYPE_LABEL[t]}</span>' for t in tags)

# ── 플로우차트 요소 ──
def nd(label, cls="llm", img=None, sub=""):
    im = f'<img class=thumb src="pipeline_doc/img/{img}" loading=lazy>' if img else ""
    subh = f'<span class=ndsub>{esc(sub)}</span>' if sub else ""
    return f'<div class="fn t-{cls}">{im}<span>{esc(label)}{subh}</span></div>'

def dec(label):
    return f'<div class="fn t-dec">◆ {esc(label)}</div>'

DOWN = '<div class=dn>↓</div>'

def col(*items):
    return '<div class=fcol>' + DOWN.join(items) + '</div>'

def branches(*cols):
    # cols: (분기라벨, 내용html)
    inner = "".join(f'<div class=br><div class=brlab>{esc(lab)}</div>{c}</div>' for lab, c in cols)
    return f'<div class=brs>{inner}</div>'

def hflow(*items):
    return '<div class=hf>' + '<span class=ar>→</span>'.join(items) + '</div>'

# ── §1 마스터 플로우차트 ──
master = col(
    nd("시나리오 PDF 업로드", "io"),
    dec("기획안 PDF도 있나?"),
    branches(("예", col(nd("planning_doc_analysis — 기획안 분석", "llm"))),
             ("아니오", col(nd("건너뜀", "code")))),
    nd("P1 텍스트 전처리 — 원고 정리·씬 나누기·요약·시각 규칙", "llm"),
    nd("P2 비트·샷 추출 — 씬을 사진 찍을 순간들로 분해", "llm"),
    nd("P3 엔티티 카드 — 인물·장소·소품·옷 카드 만들기", "llm"),
    nd("P4 연출 — 샷 선택·인물 배정·카메라·그림 주문서(T2I)", "llm"),
    dec("장소가 실내인가, 야외인가?"),
    branches(("실내", col(nd("도면(FP) 체인", "llm", "floor_plan.png"))),
             ("야외", col(dec("직행(W22) 기능 켜짐?"),
                        branches(("켜짐이면 (지금은 아님)", col(nd("직행 3단계: 명세→대표사진→위치판정", "off", "outdoor_canon_photo.png"))),
                                 ("꺼짐 (현재 상태)", col(nd("항공뷰 3종 스택", "gen", "outdoor_aerial_base.png"))))))),
    nd("P6-1 참조 사진 생성 — 얼굴→옷→합성→상태변형", "gen", "reference_composite.png"),
    nd("P6-2 배경 그림 생성 — 실내 bg + 야외 plate", "gen", "background_render_outdoor.png"),
    nd("P6-3 씬 스틸 생성 — 참조 조립→번역→생성→검증", "gen", "scene_still.png"),
    dec("어떤 형태로 내보낼까?"),
    branches(("갤러리", col(nd("갤러리·캔버스 UI", "io"))),
             ("웹북", col(nd("웹북 패키지 (LLM 분절)", "llm"), nd("웹북 PDF", "io"))),
             ("HTML", col(nd("HTML ZIP (씬글+스틸 삽입)", "io")))),
)

# ── §2 페이즈별 플로우차트 ──
p1 = col(
    nd("PDF 업로드", "io"),
    dec("기획안 PDF 있나?"),
    branches(("예", col(nd("planning_doc_analysis", "llm"))), ("아니오", col(nd("건너뜀", "code")))),
    nd("text_cleanup — 원고 청소", "llm"),
    nd("scene_segmentation — 씬 자르기", "llm"),
    nd("scene_save — 원문 저장 (LLM 없음)", "code"),
    hflow(nd("episode_summary", "llm"), nd("visual_world_rules", "llm"), nd("scene_summary ×30", "llm")),
)
p2 = col(
    nd("entity_character_list — 인물 명단 미리 뽑기", "llm"),
    nd("beat_extract — 씬→비트 ×30", "llm"),
    nd("shot_extract — 비트→샷 후보 ×30", "llm"),
    nd("shot_validator — 샷 규칙 검사 ×30", "llm"),
)
p3 = col(
    hflow(nd("인물 명단·카드", "llm"), nd("장소 명단·카드", "llm"), nd("소품 명단·카드", "llm")),
    nd("entity_merge — 중복 합치기", "llm"),
    nd("entity_relation — 관계 연결 (변형·소유)", "llm"),
    dec("3씬 이하 등장?"),
    branches(("예", col(nd("entity_filter — 제거 판단", "llm"))), ("아니오", col(nd("유지", "code")))),
    hflow(nd("entity_detail — 상세 보강", "llm"), nd("entity_t2i — 참조사진 주문서", "llm")),
)
p4 = col(
    nd("shot_selection — 씬당 최대 3샷 선택", "llm"),
    nd("scene_director — 씬별 등장 인물 확정 → VE는 코드가 자동 구축", "llm"),
    hflow(nd("shot_director 기법2종", "llm"), nd("scene_camera_flow", "llm"), nd("shot_dependency 연결", "llm")),
    hflow(nd("outlook 1·2·3 — 옷 카드", "llm"), nd("shot_staging — 배치", "llm"), nd("scene_consistency — 교차 점검", "llm")),
    nd("scene_detail — 스틸 주문서 저작", "llm"),
    dec("VE에 없는 인물을 썼나?"),
    branches(("예", col(nd("재시도 + 강제 제거 ↻", "llm"))), ("아니오", col(nd("통과", "code")))),
    hflow(nd("shot_dependency_t2i", "llm"), nd("t2i_review — 일괄 검수", "llm")),
)
p5 = col(
    nd("background_classify — 실내/야외 나누기", "llm"),
    nd("background_master_plan — 배경 계획표 (L03B01…)", "llm"),
    dec("실내? 야외?"),
    branches(
        ("실내 — 도면 체인", col(
            nd("floor_plan_prompt — 도면 주문서", "llm"),
            nd("floor_plan_render — 도면 그리기", "gen", "floor_plan.png"),
            nd("geometry_readback — VLM이 도면 되읽기", "vlm"),
            dec("마커 겹침?"),
            branches(("예", col(nd("도면 다시 그리기 ↻", "gen"))), ("아니오", col(nd("통과", "code")))),
            nd("shot_aware_bg_render_plan — 렌더 계획", "llm"),
            nd("background_prompt — '2026 대한민국' 앵커", "llm"),
        )),
        ("야외 — 항공뷰 스택", col(
            nd("outdoor_site_layout", "llm"),
            nd("항공 베이스", "gen", "outdoor_aerial_base.png"),
            nd("샷 블로킹", "gen", "outdoor_shot_blocking.png"),
            nd("카메라 스케치", "gen", "outdoor_camera_sketch.png"),
        )),
        ("야외 직행 W22 — 현재 꺼짐", col(
            nd("outdoor_place_spec — 마커 명세", "off"),
            nd("outdoor_place_canon — 대표 실사+지도 (VLM 점수 판정)", "off", "outdoor_canon_map.png"),
            nd("outdoor_shot_grounding — 위치·카메라 판정", "off"),
        )),
    ),
    hflow(nd("episode_reference_policy — 참조 규칙", "llm"), nd("연속성 앵커 (visual/zoom)", "llm"), nd("world_guide", "llm")),
)
p6 = col(
    nd("ref_image_gen — 참조 사진: 얼굴 → 옷 → 장소·소품", "gen", "reference_face.png"),
    nd("composite_image_gen — 얼굴+옷 합성 전신", "gen", "reference_composite.png"),
    nd("character_state_variant — 상태 변형 (시신 등)", "gen", "character_state_variant.png"),
    nd("background_render — 배경 그리기 (실내=도면 참조 / 야외=항공뷰 참조)", "gen", "background_render_indoor.png"),
    dec("검열(moderation)에 걸림?"),
    branches(("예", col(nd("문구 순화 후 1회 재시도 ↻", "gen"))), ("아니오", col(nd("통과", "code")))),
    nd("scene_image_pipeline — 참조 꾸러미 조립 (배경+직전샷+구도가이드+인물합성)", "code"),
    nd("prompt_translation — 주문서 영어 번역", "llm"),
    nd("스틸 생성 ×N (Gemini Image)", "gen", "scene_still.png"),
    dec("참조가 규칙대로 다 붙었나? (RCV)"),
    branches(("위반", col(nd("재시도 ↻", "gen"))), ("통과", col(nd("scene_still 확정", "io", "scene_still_2.png")))),
)
p7 = col(
    nd("분석·이미지 전부 완료", "io"),
    dec("내보내기 선택"),
    branches(
        ("갤러리", col(nd("갤러리·캔버스 UI에서 열람", "io"))),
        ("웹북", col(
            nd("generate-webbook — LLM이 연재 N화×섹션 M개로 나눔", "llm"),
            nd("webbook_package 저장", "code"),
            nd("render-pdf — 세로 웹북 PDF 조판", "code"),
        )),
        ("HTML", col(
            nd("export-html-zip", "code"),
            nd("씬 원문을 문단으로 나누고 스틸 삽입 위치 계산", "code"),
            nd("이미지 JPEG 변환(또는 원본) + ZIP", "io"),
        )),
    ),
)

# ── §3 스텝 상세 표 ──
order_key = lambda m: m["order"] if isinstance(m["order"], (int, float)) else 999
rows = []
for m in sorted(ACTIVE.values(), key=order_key):
    sid = m["id"]
    d = DESC.get(sid, ("", ""))
    app = m["applicability"]
    always = app == "always"
    appl = {"always": "항상 실행", "on_demand": "요청 시"}.get(app, "조건부")
    app_detail = "" if always or app == "on_demand" else app.replace("if_", "")
    off = sid in ("outdoor_place_spec", "outdoor_place_canon", "outdoor_shot_grounding", "shot_essence_extraction", "space_set_bg")
    tags = TYPE.get(sid, ["llm"])
    prim = "gen" if "gen" in tags else ("vlm" if "vlm" in tags else tags[0])
    cls = f"r-{prim}" + (" offrow" if off else "")
    cond_badge = "" if always else f'<span class="tg tg-cond">{esc(appl)}</span>'
    off_badge = '<span class="tg tg-off">꺼짐</span>' if off else ""
    rows.append(f"<tr class='{cls}'><td class=num>{m['order']}</td><td class=sid>{esc(sid)}</td>"
                f"<td class=nowrap>{tag_badges(sid)}{cond_badge}{off_badge}</td>"
                f"<td>{esc(MODEL_LABEL.get(m['model'], m['model']))}</td>"
                f"<td class=purpose>{esc(d[0])}</td><td>{esc(d[1])}</td>"
                f"<td class=small>{esc(app_detail)}</td></tr>")
step_table = "\n".join(rows)

dep_rows = "\n".join(f"<tr><td class=sid>{esc(m['id'])}</td><td>{esc(m['label'])}</td><td>{esc(m['lifecycle'])}</td></tr>"
                     for m in sorted(DEPR, key=order_key))

EXPORTS = [
    ("generate-webbook", "웹북 패키지 만들기",
     "시나리오 전문 + 인물 카드 + 스틸들을 모아 LLM에게 주고, 웹 연재용으로 몇 화(기본 4화) × 화당 몇 섹션(기본 10개)으로 나눠 달라고 합니다. 섹션 제목도 같이 짓습니다. 결과는 DB에 저장.",
     "전문+카드+스틸 → 연재 분절 패키지"),
    ("render-pdf", "웹북 PDF 만들기",
     "위 패키지와 스틸 이미지를 합쳐 세로로 긴 웹툰형 PDF를 조판합니다.",
     "패키지+이미지 → 웹북 PDF"),
    ("export-html-zip", "HTML 묶음 내보내기",
     "에피소드마다 HTML 한 장을 만듭니다 — 씬 원문을 문단으로 나누고, 각 씬의 선택 스틸을 본문 사이 적절한 위치에 끼워 넣습니다. 참조 사진 섹션도 포함. 이미지는 용량 절감용 JPEG 변환본과 원본 두 가지로 ZIP.",
     "전문+스틸 → 글·그림 섞인 HTML ZIP"),
]
export_rows = "\n".join(f"<tr><td class=sid>{esc(a)}</td><td class=nowrap>{esc(b)}</td><td class=purpose>{esc(c)}</td><td>{esc(d)}</td></tr>" for a,b,c,d in EXPORTS)

# ── §5 샘플 표 ──
def img_cell(fn, cap=""):
    return f'<a href="pipeline_doc/img/{fn}" target=_blank><img class=big src="pipeline_doc/img/{fn}" loading=lazy></a><div class=cap2>{esc(cap)}</div>'
def txt_cell(t):
    return f'<pre>{esc((t or "").strip()[:800])}</pre>'

shot = TS.get("shot") or {}
ent = TS.get("entity_t2i") or {}
SAMPLES = [
    ("씬 원문 (scene_save)", "모든 단계의 원천 — S11 발췌. LLM에는 항상 전문을 전달(자르기 금지)", txt_cell(TS.get("scene_text"))),
    ("episode_summary", "에피소드 줄거리 요약", txt_cell(TS.get("episode_summary"))),
    ("shot + 등장 목록(VE)", f"S{shot.get('scene')}sh{shot.get('shot')} — 샷 설명·비트·등장 인물", txt_cell(f"[샷 설명] {shot.get('desc')}\n[비트] {shot.get('beat')}\n[등장 목록 VE] {shot.get('ve')}")),
    ("entity_t2i (그림 주문서)", f"{ent.get('id')} {ent.get('name')} — 참조 사진용", txt_cell(ent.get("t2i"))),
    ("scene_detail (스틸 주문서)", "선택 샷의 최종 T2I — 인물 코드(C##O##)는 코드가 조립", txt_cell(TS.get("t2i"))),
    ("ref_image_gen — 얼굴", "인물 얼굴 참조 (증명사진풍)", img_cell("reference_face.png")),
    ("ref_image_gen — 옷", "옷(아웃룩) 참조 (마네킹 착장)", img_cell("reference_outlook.png")),
    ("composite_image_gen", "얼굴+옷 합성 전신 — 스틸 생성에 실제 첨부", img_cell("reference_composite.png")),
    ("character_state_variant", "상태 변형 (기존 사진을 i2i 변형)", img_cell("character_state_variant.png")),
    ("floor_plan_render", "실내 도면 — 마커 라벨 포함", img_cell("floor_plan.png")),
    ("도면 주문서 발췌", "도면 그리기에 실제 쓰인 프롬프트", txt_cell(TS.get("fp_prompt"))),
    ("background_render — 실내", "도면을 참조로 그린 실내 배경 (옥탑방 내부)", img_cell("background_render_indoor.png")),
    ("background_render — 야외", "항공뷰를 참조로 그린 야외 배경 (철제 증축 계단)", img_cell("background_render_outdoor.png")),
    ("배경 주문서 발췌", "'2026 대한민국' 앵커가 들어간 배경 프롬프트", txt_cell(TS.get("bg_prompt"))),
    ("outdoor_site_layout — 항공 베이스", "야외 장소 마커 항공뷰", img_cell("outdoor_aerial_base.png")),
    ("outdoor_site_layout — 샷 블로킹", "인물 위치 표시 항공뷰", img_cell("outdoor_shot_blocking.png")),
    ("outdoor_site_layout — 카메라 스케치", "카메라 위치 스케치", img_cell("outdoor_camera_sketch.png")),
    ("location_aerial", "장소 항공 캐논", img_cell("location_aerial.png")),
    ("outdoor_place_canon — 실사 [꺼짐]", "직행 체인의 장소 대표 실사 (4회차 산출)", img_cell("outdoor_canon_photo.png")),
    ("outdoor_place_canon — 지도 [꺼짐]", "직행 체인의 탑다운 지도 (4회차 산출)", img_cell("outdoor_canon_map.png")),
    ("캐논 주문서 발췌 [꺼짐]", "대표 실사 생성 프롬프트 (4회차)", txt_cell(TS.get("canon_prompt"))),
    ("zoom_continuity_anchor", "확대 연속성 크롭", img_cell("zoom_continuity_crop.png")),
    ("registered_pose_guide [꺼짐]", "실내 공유 포즈 가이드 (현재 꺼짐)", img_cell("registered_pose_guide.png")),
    ("scene_image_pipeline — 최종 스틸 1", "완성 씬 스틸", img_cell("scene_still.png")),
    ("scene_image_pipeline — 최종 스틸 2", "완성 씬 스틸 (S13)", img_cell("scene_still_2.png")),
    ("스틸 최종 프롬프트 발췌", "번역까지 끝나고 실제 생성에 쓰인 프롬프트", txt_cell(TS.get("scene_still_prompt"))),
    ("웹북/HTML 출력", "웹북 생성 이력이 아직 0건이라 이미지 샘플 없음 — 흐름은 §4 표와 P7 플로우차트 참조", "<div class=small>샘플 없음 (기존 이미지만 사용 원칙)</div>"),
]
sample_rows = "\n".join(f"<tr><td class=area>{esc(a)}</td><td class=small>{esc(b)}</td><td class=sample>{c}</td></tr>" for a,b,c in SAMPLES)

LEGEND = """
<div class=legend>
<span class="fn t-llm inline">글 LLM — 글을 읽고 쓰는 인공지능</span>
<span class="fn t-gen inline">이미지 생성 — 그림을 그리는 모델</span>
<span class="fn t-vlm inline">VLM 판독 — 그림을 '보고' 판단하는 모델</span>
<span class="fn t-code inline">코드 — 인공지능 없이 프로그램이 처리</span>
<span class="fn t-dec inline">◆ 조건 분기</span>
<span class="fn t-off inline">현재 꺼진 기능</span>
<span class="fn t-io inline">입력/산출물</span>
</div>"""

HTML_DOC = f"""<!DOCTYPE html>
<html lang=ko><head><meta charset=utf-8>
<meta name=viewport content="width=device-width, initial-scale=1">
<title>TheRoad 전체 파이프라인 — 시나리오 입력→이미지·웹북·HTML 출력</title>
<style>
:root {{ color-scheme: dark; }}
body {{ margin:0; padding:24px; background:#0f1216; color:#e6e6e6; font:14px/1.6 -apple-system,'Apple SD Gothic Neo',sans-serif; }}
h1 {{ font-size:21px; }} h2 {{ font-size:18px; margin-top:44px; border-bottom:1px solid #333; padding-bottom:6px; }}
h3 {{ font-size:15px; color:#9ecbff; margin:26px 0 8px; }}
table {{ border-collapse:collapse; width:100%; margin:12px 0; }}
th, td {{ border:1px solid #2a2f36; padding:6px 9px; text-align:left; vertical-align:top; }}
th {{ background:#1c2128; position:sticky; top:0; z-index:2; }}
td.num {{ white-space:nowrap; text-align:right; color:#8a939e; }}
td.sid {{ font-family:ui-monospace,monospace; font-size:12px; white-space:nowrap; }}
td.purpose {{ min-width:300px; }}
td.small, .small {{ color:#8a939e; font-size:12px; }}
td.area {{ font-weight:700; white-space:nowrap; }}
td.sample {{ min-width:340px; }}
td.nowrap {{ white-space:nowrap; }}
.box {{ background:#161b22; border:1px solid #2a2f36; border-radius:8px; padding:12px 16px; margin:12px 0; }}
/* 행 색: 유형별 */
tr.r-gen td {{ background:#12241a; }}
tr.r-vlm td {{ background:#241a28; }}
tr.offrow td {{ opacity:.55; }}
/* 유형 배지 */
.tg {{ display:inline-block; border-radius:9px; padding:1px 8px; font-size:11px; margin:1px 3px 1px 0; }}
.tg-llm {{ background:#1d2a3f; color:#9ec5ff; border:1px solid #35507a; }}
.tg-gen {{ background:#14432a; color:#7ee2a8; border:1px solid #2d6a4a; }}
.tg-vlm {{ background:#3d2050; color:#dbaef7; border:1px solid #6a3a90; }}
.tg-code {{ background:#3a2a12; color:#f2d9a6; border:1px solid #7a5a24; }}
.tg-cond {{ background:#4a3d17; color:#ffd76e; border:1px solid #8a7020; }}
.tg-off {{ background:#241a2e; color:#c9aee6; border:1px solid #4a3560; }}
/* 플로우차트 */
.fcol {{ display:flex; flex-direction:column; align-items:center; }}
.fn {{ background:#1d2a3f; border:1.5px solid #35507a; color:#dce9ff; border-radius:10px; padding:7px 16px; font-size:13px; max-width:560px; text-align:center; display:flex; align-items:center; gap:10px; justify-content:center; }}
.fn.inline {{ display:inline-flex; margin:3px 8px 3px 0; font-size:12px; padding:4px 10px; }}
.fn.t-gen {{ background:#12341f; border-color:#2d6a4a; color:#b9edd2; }}
.fn.t-vlm {{ background:#2e1a3d; border-color:#6a3a90; color:#e3c2f7; }}
.fn.t-code {{ background:#2a2012; border-color:#7a5a24; color:#e8d5aa; }}
.fn.t-io {{ background:#3a2a12; border-color:#a97c2a; color:#f2d9a6; font-weight:600; }}
.fn.t-off {{ background:#241a2e; border-color:#4a3560; color:#c9aee6; }}
.fn.t-dec {{ background:#2e2810; border:2px dashed #b08c22; color:#ffd76e; border-radius:14px; font-weight:700; }}
.ndsub {{ display:block; font-size:11px; color:#8a939e; font-weight:400; }}
.dn {{ color:#9db4cc; font-size:30px; font-weight:700; line-height:1; padding:8px 0; text-shadow:0 0 6px rgba(120,170,255,.35); }}
/* 분기 그룹 전체를 점선 큰 박스로 감쌈 */
.brs {{ display:flex; gap:22px; align-items:stretch; justify-content:center; flex-wrap:wrap; margin:4px 0;
        border:2px dashed #b08c22; border-radius:18px; background:rgba(176,140,34,.06); padding:16px 18px; }}
/* 각 가지: 배경색을 서로 다르게 */
.br {{ display:flex; flex-direction:column; align-items:center; border:1.5px dashed #3a4656; border-radius:14px; padding:12px 16px 14px; min-width:150px; }}
.brs > .br:nth-of-type(1) {{ background:rgba(45,106,74,.14); border-color:#3f7a58; }}
.brs > .br:nth-of-type(2) {{ background:rgba(90,110,150,.14); border-color:#54678a; }}
.brs > .br:nth-of-type(3) {{ background:rgba(106,58,144,.14); border-color:#6a4a90; }}
.brlab {{ font-size:13px; font-weight:800; color:#0f1216; background:#ffd76e; padding:3px 16px; border-radius:12px; margin:-24px 0 10px; box-shadow:0 0 0 4px #12161c; }}
.hf {{ display:flex; flex-wrap:wrap; align-items:center; gap:8px; justify-content:center; }}
.ar {{ color:#9db4cc; font-size:22px; font-weight:700; }}
.thumb {{ height:56px; border-radius:6px; border:1px solid #2a2f36; }}
img.big {{ max-width:340px; max-height:220px; border-radius:6px; border:1px solid #2a2f36; display:block; }}
pre {{ background:#161b22; border:1px solid #2a2f36; padding:9px; white-space:pre-wrap; overflow-x:auto; font-size:12px; margin:0; max-height:260px; overflow-y:auto; }}
.cap2 {{ color:#8a939e; font-size:11px; margin-top:3px; }}
.tablewrap {{ overflow-x:auto; }}
.legend {{ margin-top:8px; }}
.fcwrap {{ background:#12161c; border:1px solid #232a33; border-radius:12px; padding:16px 10px; margin:10px 0; overflow-x:auto; }}
</style></head><body>
<h1>TheRoad 전체 파이프라인 — 시나리오 입력 → 이미지 · 웹북 · HTML 출력</h1>
<div class=box>
<b>무엇을 하는 시스템인가</b>: 시나리오 PDF 한 편을 넣으면 → 씬을 나누고 → 인물·장소·소품 카드를 만들고 → 씬마다 스틸컷(그림)을 뽑아 → 웹북(PDF)이나 HTML로 내보냅니다.<br>
<b>근거</b>: 코드 원장 <code>step_manifest.py</code>(active 65스텝) + 내보내기 API 코드. 모델 표기는 2026-07-11 현행. 샘플 이미지는 5회차 E2E 기존 산출물을 <u>복사</u>(직행 캐논만 4회차) — 새로 만든 그림 없음.<br>
<b>색상 범례</b> — 노드·표 행 모두 같은 색 규칙:
{LEGEND}
</div>

<h2>1. 전체 플로우차트</h2>
<div class=fcwrap>{master}</div>

<h2>2. 단계별 플로우차트</h2>
<h3>P1 — 입력·텍스트 전처리: 원고를 깨끗이 하고 씬으로 나눔</h3><div class=fcwrap>{p1}</div>
<h3>P2 — 비트·샷 추출: 씬을 '사진 찍을 순간'으로 분해</h3><div class=fcwrap>{p2}</div>
<h3>P3 — 엔티티 카드: 인물·장소·소품 카드 만들기</h3><div class=fcwrap>{p3}</div>
<h3>P4 — 연출: 샷 고르고, 인물 배정하고, 그림 주문서 쓰기</h3><div class=fcwrap>{p4}</div>
<h3>P5 — 배경 계획: 실내는 도면 방식, 야외는 항공뷰 방식 (조건 분기)</h3><div class=fcwrap>{p5}</div>
<h3>P6 — 이미지 생성: 참조 사진 → 배경 → 최종 스틸 (재시도 루프 포함)</h3><div class=fcwrap>{p6}</div>
<h3>P7 — 출력: 갤러리 / 웹북 PDF / HTML ZIP</h3><div class=fcwrap>{p7}</div>

<h2>3. 스텝 상세 표 — 쉬운 설명 (active 65스텝, 실행 순서대로)</h2>
<p class=small>행 색: 초록=이미지 생성 개입, 보라=VLM(그림을 보고 판단) 개입, 흐림=현재 꺼진 기능. 배지: 유형 + 조건부 여부.</p>
<div class=tablewrap><table>
<tr><th>순서</th><th>스텝</th><th>유형</th><th>모델</th><th>무엇을 하나 (쉬운 설명)</th><th>입력 → 출력</th><th>조건</th></tr>
{step_table}
</table></div>

<h2>4. 내보내기 (웹북 · PDF · HTML) — 쉬운 설명</h2>
<div class=tablewrap><table>
<tr><th>기능</th><th>이름</th><th>무엇을 하나</th><th>입력 → 출력</th></tr>
{export_rows}
</table></div>

<h2>5. 단계별 실제 샘플 (기존 산출물 복사 — 새 생성 0)</h2>
<div class=tablewrap><table>
<tr><th>단계</th><th>설명</th><th>샘플</th></tr>
{sample_rows}
</table></div>

<h2>6. 지금은 쓰지 않는(deprecated) 스텝 — 삭제 금지 규칙으로 보존</h2>
<div class=tablewrap><table>
<tr><th>스텝</th><th>라벨</th><th>상태</th></tr>
{dep_rows}
</table></div>

<div class=box>
<h3 style="margin-top:0">알아두면 좋은 규칙 (쉬운 판)</h3>
<ul style="font-size:13px">
<li>스틸 한 장 = 샷 하나. 샷마다 촬영 기법 2가지 → 그림도 2가지 버전이 나옵니다.</li>
<li>샷에 누가 나오는지(VE)는 LLM이 아니라 <b>프로그램이</b> 감독 확정 데이터에서 만듭니다 — 주문서가 그 명단을 어기면 자동으로 걸러냅니다.</li>
<li>모델 설정을 바꾸면 해당 스텝의 완료 기록이 '낡음(stale)'으로 바뀌어 다시 돌릴 수 있게 됩니다 (config_hash).</li>
<li>그림 주문서는 생성 직전에 <b>영어로 번역</b>됩니다 — 번역이 문장 구조를 뭉개는 문제가 직행 체인에서 발견된 적 있음.</li>
<li>스틸 데이터는 지우고 다시 쓰지 않고 <b>덮어쓰기(UPSERT)</b>만 합니다 — 이미지 연결이 끊기지 않게.</li>
</ul>
</div>
<p class=small>생성 2026-07-11 · 빌더 scratchpad/forest_exp/pipeline_doc/build_doc.py · 코드 근거: step_manifest.py / export_service.py / exports.py / webbook_generator.py / pdf_renderer.py</p>
</body></html>"""

with open(OUT, "w") as f:
    f.write(HTML_DOC)
print("written", os.path.abspath(OUT), len(HTML_DOC))
