#!/usr/bin/env python3
"""
인생네컷 부스 - 프로젝트 설치 스크립트

사용법:
    python3 setup_life4cut.py              # ./life4cut 에 설치
    python3 setup_life4cut.py ~/booth      # 경로 지정
    python3 setup_life4cut.py --no-venv    # 가상환경 없이 파일만 생성

FastAPI 서버 + 순수 JS 프론트엔드를 한 번에 만들고, 가상환경과 의존성까지 설치합니다.
"""

import os
import subprocess
import sys
from pathlib import Path

FILES = {}

# =====================================================================
# 서버
# =====================================================================

FILES["requirements.txt"] = """fastapi>=0.110
uvicorn[standard]>=0.27
python-multipart>=0.0.9
"""

FILES["app/__init__.py"] = ""

FILES["app/config.py"] = r'''"""경로, 포트, 공개 주소 등 한곳에 모은 설정."""
import os
import socket
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent
WEB_DIR = BASE_DIR / "web"
DATA_DIR = BASE_DIR / "data"

HOST = os.environ.get("BOOTH_HOST", "0.0.0.0")
PORT = int(os.environ.get("BOOTH_PORT", "8000"))

# 결과물 보관 시간(시간 단위). 지나면 자동 삭제합니다.
RETENTION_HOURS = float(os.environ.get("BOOTH_RETENTION_HOURS", "6"))

# 업로드 1건당 최대 용량
MAX_UPLOAD_MB = int(os.environ.get("BOOTH_MAX_UPLOAD_MB", "120"))


def lan_ip() -> str:
    """휴대폰이 접속할 수 있는 이 PC의 랜 IP를 찾습니다."""
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect(("8.8.8.8", 80))
        return s.getsockname()[0]
    except OSError:
        return "127.0.0.1"
    finally:
        s.close()


def public_base_url() -> str:
    """QR에 넣을 주소. 도메인이 있으면 BOOTH_PUBLIC_URL 로 덮어쓰세요."""
    override = os.environ.get("BOOTH_PUBLIC_URL")
    if override:
        return override.rstrip("/")
    return "http://{}:{}".format(lan_ip(), PORT)
'''

FILES["app/storage.py"] = r'''"""촬영 결과를 세션 폴더에 저장하고 오래된 것을 지웁니다."""
import re
import secrets
import shutil
import time
from pathlib import Path

from .config import DATA_DIR, MAX_UPLOAD_MB, RETENTION_HOURS

ID_RE = re.compile(r"^[a-f0-9]{8}$")
PHOTO_NAME = "photo.png"
VIDEO_EXT = {"video/mp4": "mp4", "video/webm": "webm"}


def new_id() -> str:
    return secrets.token_hex(4)


def valid_id(sid: str) -> bool:
    return bool(ID_RE.match(sid or ""))


def session_dir(sid: str) -> Path:
    return DATA_DIR / sid


async def save_upload(sid: str, upload, filename: str) -> int:
    """업로드를 세션 폴더에 저장하고 바이트 수를 돌려줍니다."""
    d = session_dir(sid)
    d.mkdir(parents=True, exist_ok=True)
    limit = MAX_UPLOAD_MB * 1024 * 1024
    written = 0
    with open(d / filename, "wb") as f:
        while True:
            chunk = await upload.read(1024 * 512)
            if not chunk:
                break
            written += len(chunk)
            if written > limit:
                f.close()
                shutil.rmtree(d, ignore_errors=True)
                raise ValueError("파일이 너무 큽니다")
            f.write(chunk)
    return written


def video_name(content_type: str, filename: str) -> str:
    ext = VIDEO_EXT.get((content_type or "").split(";")[0].strip())
    if not ext:
        ext = "mp4" if str(filename).lower().endswith(".mp4") else "webm"
    return "video." + ext


def list_files(sid: str):
    d = session_dir(sid)
    if not d.is_dir():
        return []
    out = []
    for p in sorted(d.iterdir()):
        if not p.is_file():
            continue
        out.append(
            {
                "name": p.name,
                "kind": "photo" if p.name == PHOTO_NAME else "video",
                "size": p.stat().st_size,
                "url": "/files/{}/{}".format(sid, p.name),
            }
        )
    return out


def purge_old() -> int:
    """보관 시간이 지난 세션 폴더를 정리합니다."""
    if not DATA_DIR.is_dir():
        return 0
    cutoff = time.time() - RETENTION_HOURS * 3600
    removed = 0
    for d in DATA_DIR.iterdir():
        if d.is_dir() and d.stat().st_mtime < cutoff:
            shutil.rmtree(d, ignore_errors=True)
            removed += 1
    return removed
'''

FILES["app/routes.py"] = r'''"""업로드 API와 다운로드 페이지 라우트."""
from typing import Optional

from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi.responses import FileResponse

from .config import WEB_DIR, public_base_url
from . import storage

router = APIRouter()


@router.post("/api/sessions")
async def create_session(
    image: UploadFile = File(...),
    video: Optional[UploadFile] = File(None),
):
    """완성된 사진(+촬영 영상)을 받아 공유 주소를 돌려줍니다."""
    storage.purge_old()
    sid = storage.new_id()
    try:
        await storage.save_upload(sid, image, storage.PHOTO_NAME)
        if video is not None and video.filename:
            name = storage.video_name(video.content_type, video.filename)
            await storage.save_upload(sid, video, name)
    except ValueError as e:
        raise HTTPException(status_code=413, detail=str(e))
    return {"id": sid, "url": "{}/s/{}".format(public_base_url(), sid)}


@router.get("/api/sessions/{sid}")
def read_session(sid: str):
    if not storage.valid_id(sid):
        raise HTTPException(status_code=404, detail="없는 주소입니다")
    files = storage.list_files(sid)
    if not files:
        raise HTTPException(status_code=404, detail="만료되었거나 없는 사진입니다")
    return {"id": sid, "files": files}


@router.get("/s/{sid}")
def download_page(sid: str):
    if not storage.valid_id(sid):
        raise HTTPException(status_code=404, detail="없는 주소입니다")
    return FileResponse(WEB_DIR / "download.html")


@router.get("/files/{sid}/{name}")
def download_file(sid: str, name: str, download: int = 0):
    """미리보기는 그대로, download=1 이면 저장용 이름을 붙여 내려보냅니다."""
    if not storage.valid_id(sid) or "/" in name or ".." in name:
        raise HTTPException(status_code=404, detail="없는 파일입니다")
    path = storage.session_dir(sid) / name
    if not path.is_file():
        raise HTTPException(status_code=404, detail="만료되었거나 없는 파일입니다")
    if download:
        return FileResponse(path, filename="인생네컷_{}_{}".format(sid, name))
    return FileResponse(path)
'''

FILES["app/main.py"] = r'''"""앱 진입점. python -m app.main 으로 실행합니다."""
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from .config import DATA_DIR, HOST, PORT, WEB_DIR, public_base_url
from .routes import router
from . import storage


@asynccontextmanager
async def lifespan(app: FastAPI):
    DATA_DIR.mkdir(parents=True, exist_ok=True)
    storage.purge_old()
    yield


app = FastAPI(title="인생네컷 부스", lifespan=lifespan)
app.include_router(router)
app.mount("/static", StaticFiles(directory=WEB_DIR), name="static")


@app.get("/")
def booth():
    return FileResponse(WEB_DIR / "index.html")


@app.get("/health")
def health():
    return {"ok": True, "public_url": public_base_url()}


if __name__ == "__main__":
    import uvicorn

    print("촬영 화면 : http://localhost:{}".format(PORT))
    print("QR 주소   : {}".format(public_base_url()))
    uvicorn.run("app.main:app", host=HOST, port=PORT, log_level="info")
'''

# =====================================================================
# 프론트엔드 - HTML
# =====================================================================

FILES["web/index.html"] = r'''<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>인생네컷</title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<div id="stage">

  <header class="topbar">
    <h1 class="wordmark">인생<em>네컷</em></h1>
    <ol class="steps" id="steps">
      <li data-s="1">프레임</li>
      <li data-s="2">촬영</li>
      <li data-s="3">고르기</li>
      <li data-s="4">받기</li>
    </ol>
  </header>

  <!-- 1 -->
  <section class="screen is-on" id="s-frame">
    <h2 class="head">프레임을 고르세요</h2>
    <p class="lede">10초마다 여섯 장을 찍고, 그중 네 장을 골라 담습니다.</p>
    <div class="rack" id="rack"></div>
    <div class="dock">
      <div class="notice" id="camerr" hidden></div>
      <button class="btn btn-primary" id="go-shoot">촬영 시작하기</button>
    </div>
  </section>

  <!-- 2 -->
  <section class="screen" id="s-shoot">
    <h2 class="head" id="shoot-head">준비되면 시작을 누르세요</h2>
    <p class="lede" id="shoot-lede">시작하면 녹화가 켜지고 10초 뒤 첫 장이 찍힙니다.</p>
    <div class="viewport"><canvas id="preview" width="1200" height="900"></canvas></div>
    <div class="filmstrip" id="filmstrip"></div>
    <p class="status" id="shoot-status" aria-live="polite"></p>
    <div class="dock">
      <button class="btn btn-primary" id="start-shoot">시작</button>
      <button class="btn btn-quiet" id="abort">뒤로</button>
    </div>
  </section>

  <!-- 3 -->
  <section class="screen" id="s-choose">
    <h2 class="head">네 장을 고르세요</h2>
    <p class="lede">누른 순서대로 담깁니다. 다시 누르면 빠집니다.</p>
    <div class="chooser">
      <div class="thumbs" id="thumbs"></div>
      <div class="side"><canvas id="live-collage"></canvas></div>
    </div>
    <p class="status" aria-live="polite"><b id="pick-count">0</b> / 4</p>
    <div class="dock">
      <button class="btn btn-primary" id="finish" disabled>완성하기</button>
      <button class="btn btn-quiet" id="reshoot">다시 찍기</button>
    </div>
  </section>

  <!-- 4 -->
  <section class="screen" id="s-done">
    <h2 class="head">다 나왔어요</h2>
    <p class="lede">휴대폰으로 QR을 찍으면 사진과 영상을 받을 수 있습니다.</p>
    <div class="result">
      <div class="result-photo"><img id="final-img" alt="완성된 인생네컷"></div>
      <div class="result-side">
        <div class="qr-card">
          <div class="qr-box"><canvas id="qr"></canvas></div>
          <p class="qr-note" id="qr-note">주소를 만드는 중</p>
        </div>
        <p class="hint">사진은 6시간 뒤 자동으로 지워집니다.</p>
      </div>
    </div>
    <div class="dock">
      <button class="btn btn-primary" id="again">처음으로</button>
    </div>
  </section>

</div>
<script type="module" src="/static/js/booth.js"></script>
</body>
</html>
'''

FILES["web/download.html"] = r'''<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>인생네컷 받기</title>
<style>
  :root{ --ink:#141118; --paper:#F6F3EF; --line:#2C2634; --accent:#FFD23F; }
  *{box-sizing:border-box}
  body{margin:0;background:var(--ink);color:var(--paper);
       font-family:"Apple SD Gothic Neo","Noto Sans KR","Malgun Gothic",system-ui,sans-serif;
       padding:28px 20px 56px;line-height:1.55}
  h1{font-size:26px;font-weight:800;letter-spacing:-.04em;margin:0 0 4px}
  p.sub{color:#9A92A8;margin:0 0 24px;font-size:15px}
  img.shot{width:100%;max-width:420px;border-radius:10px;display:block;margin:0 auto 22px;
           box-shadow:0 18px 40px rgba(0,0,0,.5)}
  video{width:100%;max-width:420px;border-radius:10px;display:block;margin:0 auto 22px;background:#000}
  a.btn,button.btn{display:block;width:100%;max-width:420px;margin:0 auto 12px;padding:16px;
      border:0;border-radius:999px;background:var(--accent);color:#241A00;
      font-size:17px;font-weight:700;text-align:center;text-decoration:none;font-family:inherit}
  a.ghost{background:transparent;color:var(--paper);border:1.5px solid var(--line)}
  .msg{max-width:420px;margin:40px auto;text-align:center;color:#9A92A8}
  .tip{max-width:420px;margin:20px auto 0;font-size:13px;color:#7E768C;text-align:center}
</style>
</head>
<body>
  <h1>인생네컷</h1>
  <p class="sub" id="sub">불러오는 중</p>
  <div id="body"></div>
<script>
const sid = location.pathname.split("/").filter(Boolean).pop();
const body = document.getElementById("body");
const sub = document.getElementById("sub");

fetch("/api/sessions/" + sid).then(r => {
  if (!r.ok) throw new Error("만료되었거나 없는 사진입니다");
  return r.json();
}).then(data => {
  sub.textContent = "저장 버튼을 누르거나 사진을 길게 눌러 저장하세요.";
  const photo = data.files.find(f => f.kind === "photo");
  const video = data.files.find(f => f.kind === "video");
  if (photo) {
    const im = new Image();
    im.src = photo.url; im.className = "shot"; im.alt = "인생네컷 사진";
    body.appendChild(im);
    const a = document.createElement("a");
    a.className = "btn"; a.href = photo.url + "?download=1"; a.setAttribute("download", "");
    a.textContent = "사진 저장";
    body.appendChild(a);
  }
  if (video) {
    const v = document.createElement("video");
    v.src = video.url; v.controls = true; v.playsInline = true; v.muted = true;
    body.appendChild(v);
    const a = document.createElement("a");
    a.className = "btn ghost"; a.href = video.url + "?download=1"; a.setAttribute("download", "");
    a.textContent = "촬영 영상 저장";
    body.appendChild(a);
  }
  const tip = document.createElement("p");
  tip.className = "tip";
  tip.textContent = "이 주소는 6시간 뒤 사라집니다.";
  body.appendChild(tip);
}).catch(e => {
  sub.textContent = "";
  body.innerHTML = '<p class="msg">' + e.message + "<br>부스에서 다시 촬영해 주세요.</p>";
});
</script>
</body>
</html>
'''

# =====================================================================
# 프론트엔드 - CSS (1080x1920 세로 화면 기준으로 그리고 화면에 맞춰 축소)
# =====================================================================

FILES["web/css/app.css"] = r''':root{
  --ink:#141118;
  --panel:#1E1926;
  --line:#332B40;
  --paper:#F6F3EF;
  --muted:#9A92A8;
  --accent:#FFD23F;
  --live:#FF4E7A;
  --ok:#6FE3C4;
  --sans:"Apple SD Gothic Neo","Noto Sans KR","Malgun Gothic",system-ui,sans-serif;
}
*{box-sizing:border-box}
html,body{height:100%;margin:0;overflow:hidden;background:#0B090F}
body{font-family:var(--sans);color:var(--paper);-webkit-tap-highlight-color:transparent}
button{font-family:inherit}
:focus-visible{outline:4px solid var(--accent);outline-offset:4px}

/* 1080x1920 무대를 그리고, 실제 화면 크기에 맞춰 통째로 축소합니다. */
#stage{
  position:absolute;top:50%;left:50%;
  width:1080px;height:1920px;
  transform:translate(-50%,-50%) scale(var(--s,1));
  background:var(--ink);
  background-image:radial-gradient(90% 55% at 50% 0%, #2A2136 0%, rgba(42,33,54,0) 62%);
  padding:0 60px;display:flex;flex-direction:column;
}

.topbar{display:flex;align-items:center;justify-content:space-between;padding:52px 0 22px;flex:0 0 auto}
.wordmark{margin:0;font-size:40px;font-weight:800;letter-spacing:-.05em}
.wordmark em{font-style:normal;color:var(--accent)}
.steps{display:flex;gap:10px;margin:0;padding:0;list-style:none;counter-reset:step}
.steps li{
  counter-increment:step;font-size:20px;font-weight:600;color:var(--muted);
  padding:10px 20px;border-radius:999px;background:rgba(255,255,255,.05);
}
.steps li::before{content:counter(step) " ";opacity:.6}
.steps li.on{background:var(--accent);color:#241A00}
.steps li.done{color:var(--ok)}

.screen{display:none;flex:1 1 auto;flex-direction:column;min-height:0;padding-bottom:40px}
.screen.is-on{display:flex}

.head{font-size:64px;font-weight:800;letter-spacing:-.05em;line-height:1.08;margin:26px 0 12px}
.lede{font-size:26px;color:var(--muted);margin:0 0 30px;line-height:1.5}
.status{font-size:24px;color:var(--muted);margin:20px 0 0;text-align:center;min-height:34px}
.status b{color:var(--accent);font-size:30px}

.dock{margin-top:auto;display:flex;flex-wrap:wrap;gap:20px;padding-top:34px}
.btn{
  flex:1;border:0;border-radius:999px;padding:34px;font-size:32px;font-weight:800;
  letter-spacing:-.02em;cursor:pointer;transition:transform .12s ease,filter .12s ease;
}
.btn-primary{background:var(--accent);color:#241A00}
.btn-quiet{flex:0 0 300px;background:transparent;color:var(--paper);border:2px solid var(--line)}
.btn:active{transform:scale(.985)}
.btn:disabled{opacity:.35;cursor:not-allowed}

/* 1. 프레임 */
.rack{display:flex;gap:56px;justify-content:center;align-items:flex-start;padding-top:20px}
.pick{background:none;border:0;padding:0;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:22px}
.pick canvas{
  width:352px;height:auto;display:block;border-radius:6px;
  box-shadow:0 22px 50px rgba(0,0,0,.55);
  transition:transform .18s ease,box-shadow .18s ease;
}
.pick .name{font-size:26px;font-weight:700;color:var(--muted)}
.pick[aria-pressed="true"] canvas{transform:translateY(-14px);box-shadow:0 0 0 5px var(--accent),0 26px 56px rgba(0,0,0,.6)}
.pick[aria-pressed="true"] .name{color:var(--accent)}

/* 2. 촬영 */
.viewport{border-radius:26px;overflow:hidden;background:#000;box-shadow:0 26px 60px rgba(0,0,0,.55)}
.viewport canvas{width:100%;display:block;aspect-ratio:4/3}
.filmstrip{display:flex;gap:14px;margin-top:26px}
.cell{
  flex:1;aspect-ratio:4/3;border-radius:14px;border:3px dashed var(--line);background:var(--panel);
  overflow:hidden;display:grid;place-items:center;color:var(--muted);font-size:26px;font-weight:700;
}
.cell.filled{border-style:solid;border-color:var(--ok)}
.cell img{width:100%;height:100%;object-fit:cover;display:block}

/* 3. 고르기 */
.chooser{display:flex;gap:40px;min-height:0}
.thumbs{flex:1;display:grid;grid-template-columns:repeat(2,1fr);gap:20px;align-content:start}
.thumb{position:relative;border:0;padding:0;background:none;cursor:pointer;border-radius:16px;overflow:hidden;aspect-ratio:4/3}
.thumb img{width:100%;height:100%;object-fit:cover;display:block;transition:filter .15s ease}
.thumb[aria-pressed="false"] img{filter:saturate(.5) brightness(.6)}
.thumb[aria-pressed="true"]{box-shadow:0 0 0 5px var(--accent)}
.thumb .badge{
  position:absolute;right:14px;bottom:14px;width:54px;height:54px;border-radius:50%;
  display:grid;place-items:center;font-size:26px;font-weight:800;
  background:rgba(20,17,24,.7);border:3px solid rgba(255,255,255,.35);color:var(--paper);
}
.thumb[aria-pressed="true"] .badge{background:var(--accent);border-color:var(--accent);color:#241A00}
.side{flex:0 0 300px}
.side canvas{width:300px;height:auto;display:block;border-radius:6px;box-shadow:0 20px 46px rgba(0,0,0,.55)}

/* 4. 결과 */
.result{display:flex;gap:52px;align-items:flex-start;min-height:0}
.result-photo{flex:0 0 420px;background:var(--panel);border-radius:24px;padding:26px;display:grid;place-items:center}
.result-photo img{width:100%;height:auto;display:block;border-radius:8px;box-shadow:0 20px 46px rgba(0,0,0,.55)}
.result-side{flex:1;display:flex;flex-direction:column;gap:26px}
.qr-card{background:var(--panel);border:2px solid var(--line);border-radius:24px;padding:34px;text-align:center}
.qr-box{background:#fff;border-radius:18px;padding:22px;display:grid;place-items:center;margin-bottom:22px}
.qr-box canvas{width:100%;height:auto;image-rendering:pixelated;display:block}
.qr-note{margin:0;font-size:24px;color:var(--muted);line-height:1.5;word-break:break-all}
.hint{margin:0;font-size:22px;color:var(--muted);text-align:center}

.notice{
  background:rgba(255,78,122,.12);border:2px solid rgba(255,78,122,.45);color:#FFC5D6;
  border-radius:18px;padding:24px 28px;font-size:24px;line-height:1.5;flex:1 0 100%;
}

@media (prefers-reduced-motion:reduce){*{animation:none !important;transition:none !important}}
'''

# =====================================================================
# 프론트엔드 - JS
# =====================================================================

FILES["web/js/qr.js"] = r'''// QR 인코더 (byte mode, EC L/M, 버전 1~15). 외부 의존성 없음.
const EXP = new Uint8Array(512), LOG = new Uint8Array(256);
(function () {
  let x = 1;
  for (let i = 0; i < 255; i++) { EXP[i] = x; LOG[x] = i; x <<= 1; if (x & 0x100) x ^= 0x11d; }
  for (let j = 255; j < 512; j++) EXP[j] = EXP[j - 255];
})();
const gmul = (a, b) => (a === 0 || b === 0) ? 0 : EXP[LOG[a] + LOG[b]];

const ECC = {
  L: [null, [7,1,19,0,0],[10,1,34,0,0],[15,1,55,0,0],[20,1,80,0,0],[26,1,108,0,0],
      [18,2,68,0,0],[20,2,78,0,0],[24,2,97,0,0],[30,2,116,0,0],[18,2,68,2,69],
      [20,4,81,0,0],[24,2,92,2,93],[26,4,107,0,0],[30,3,115,1,116],[22,5,87,1,88]],
  M: [null, [10,1,16,0,0],[16,1,28,0,0],[26,1,44,0,0],[18,2,32,0,0],[24,2,43,0,0],
      [16,4,27,0,0],[18,4,31,0,0],[22,2,38,2,39],[22,3,36,2,37],[26,4,43,1,44],
      [30,1,50,4,51],[22,6,36,2,37],[22,8,37,1,38],[24,4,40,5,41],[24,5,41,5,42]]
};
const EC_BITS = { L: 1, M: 0 };
const ALIGN = [null, [], [6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],
  [6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70]];

const capacity = (lv, v) => { const t = ECC[lv][v]; return t[1] * t[2] + t[3] * t[4]; };

function utf8(str) {
  const out = [];
  for (let i = 0; i < str.length; i++) {
    const c = str.charCodeAt(i);
    if (c < 0x80) out.push(c);
    else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
    else if (c >= 0xd800 && c <= 0xdbff && i + 1 < str.length) {
      const c2 = str.charCodeAt(++i);
      const cp = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00);
      out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 63), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63));
    } else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
  }
  return out;
}
function rsGen(n) {
  let g = [1];
  for (let i = 0; i < n; i++) {
    const ng = new Array(g.length + 1).fill(0);
    for (let j = 0; j < g.length; j++) { ng[j] ^= g[j]; ng[j + 1] ^= gmul(g[j], EXP[i]); }
    g = ng;
  }
  return g;
}
function rsEncode(data, n) {
  const g = rsGen(n), res = new Array(n).fill(0);
  for (let k = 0; k < data.length; k++) {
    const factor = data[k] ^ res[0];
    res.shift(); res.push(0);
    for (let i = 0; i < n; i++) res[i] ^= gmul(g[i + 1], factor);
  }
  return res;
}
function formatBits(lv, mask) {
  const data = (EC_BITS[lv] << 3) | mask;
  let d = data << 10;
  for (let i = 4; i >= 0; i--) if (d & (1 << (i + 10))) d ^= 0x537 << i;
  return ((data << 10) | d) ^ 0x5412;
}
function versionBits(v) {
  let d = v << 12;
  for (let i = 5; i >= 0; i--) if (d & (1 << (i + 12))) d ^= 0x1f25 << i;
  return (v << 12) | d;
}

export function encode(text, level = "M") {
  const bytes = utf8(text);
  let v = 0;
  for (let i = 1; i <= 15; i++) {
    const cc = i < 10 ? 8 : 16;
    if (capacity(level, i) * 8 >= 4 + cc + bytes.length * 8) { v = i; break; }
  }
  if (!v) { if (level !== "L") return encode(text, "L"); throw new Error("QR 용량 초과"); }

  const t = ECC[level][v], total = capacity(level, v), bits = [];
  const push = (val, n) => { for (let k = n - 1; k >= 0; k--) bits.push((val >> k) & 1); };
  push(4, 4);
  push(bytes.length, v < 10 ? 8 : 16);
  for (const b of bytes) push(b, 8);
  push(0, Math.min(4, total * 8 - bits.length));
  while (bits.length % 8) bits.push(0);
  const pads = [0xec, 0x11];
  let p = 0;
  while (bits.length < total * 8) push(pads[p++ % 2], 8);

  const cw = [];
  for (let i = 0; i < bits.length; i += 8) {
    let b = 0;
    for (let j = 0; j < 8; j++) b = (b << 1) | bits[i + j];
    cw.push(b);
  }
  const blocks = [], ecb = [];
  let pos = 0;
  for (let i = 0; i < t[1]; i++) { blocks.push(cw.slice(pos, pos + t[2])); pos += t[2]; }
  for (let i = 0; i < t[3]; i++) { blocks.push(cw.slice(pos, pos + t[4])); pos += t[4]; }
  for (const b of blocks) ecb.push(rsEncode(b, t[0]));

  const maxD = Math.max(t[2], t[4] || 0), out = [];
  for (let i = 0; i < maxD; i++) for (let j = 0; j < blocks.length; j++) if (i < blocks[j].length) out.push(blocks[j][i]);
  for (let i = 0; i < t[0]; i++) for (let j = 0; j < ecb.length; j++) out.push(ecb[j][i]);

  const finalBits = [];
  for (const b of out) for (let j = 7; j >= 0; j--) finalBits.push((b >> j) & 1);
  return build(v, level, finalBits);
}

function build(v, level, dataBits) {
  const size = v * 4 + 17, m = [], fn = [];
  for (let i = 0; i < size; i++) { m.push(new Array(size).fill(0)); fn.push(new Array(size).fill(false)); }
  const set = (y, x, val) => { m[y][x] = val; fn[y][x] = true; };

  function finder(y, x) {
    for (let r = -1; r <= 7; r++) for (let c = -1; c <= 7; c++) {
      const yy = y + r, xx = x + c;
      if (yy < 0 || yy >= size || xx < 0 || xx >= size) continue;
      const inb = (r >= 0 && r <= 6 && (c === 0 || c === 6)) || (c >= 0 && c <= 6 && (r === 0 || r === 6)) ||
                  (r >= 2 && r <= 4 && c >= 2 && c <= 4);
      set(yy, xx, inb ? 1 : 0);
    }
  }
  finder(0, 0); finder(0, size - 7); finder(size - 7, 0);

  const al = ALIGN[v];
  for (let i = 0; i < al.length; i++) for (let j = 0; j < al.length; j++) {
    const ay = al[i], ax = al[j];
    if ((ay <= 8 && ax <= 8) || (ay <= 8 && ax >= size - 9) || (ay >= size - 9 && ax <= 8)) continue;
    for (let r = -2; r <= 2; r++) for (let c = -2; c <= 2; c++)
      set(ay + r, ax + c, Math.max(Math.abs(r), Math.abs(c)) !== 1 ? 1 : 0);
  }
  for (let i = 8; i < size - 8; i++) { set(6, i, i % 2 === 0 ? 1 : 0); set(i, 6, i % 2 === 0 ? 1 : 0); }
  set(size - 8, 8, 1);
  for (let i = 0; i < 9; i++) { if (!fn[8][i]) set(8, i, 0); if (!fn[i][8]) set(i, 8, 0); }
  for (let i = 0; i < 8; i++) {
    if (!fn[8][size - 1 - i]) set(8, size - 1 - i, 0);
    if (!fn[size - 1 - i][8]) set(size - 1 - i, 8, 0);
  }
  if (v >= 7) {
    const vb = versionBits(v);
    for (let i = 0; i < 18; i++) {
      const bit = (vb >> i) & 1;
      set(Math.floor(i / 3), size - 11 + (i % 3), bit);
      set(size - 11 + (i % 3), Math.floor(i / 3), bit);
    }
  }
  let idx = 0, up = true;
  for (let col = size - 1; col > 0; col -= 2) {
    if (col === 6) col--;
    for (let k = 0; k < size; k++) {
      const row = up ? size - 1 - k : k;
      for (let t = 0; t < 2; t++) {
        const cc = col - t;
        if (fn[row][cc]) continue;
        m[row][cc] = idx < dataBits.length ? dataBits[idx] : 0;
        idx++;
      }
    }
    up = !up;
  }
  let best = null, bestScore = Infinity;
  for (let msk = 0; msk < 8; msk++) {
    const cand = applyMask(m, fn, size, msk);
    placeFormat(cand, size, level, msk);
    const s = penalty(cand, size);
    if (s < bestScore) { bestScore = s; best = cand; }
  }
  return { size, version: v, modules: best };
}

function maskFn(n, r, c) {
  switch (n) {
    case 0: return (r + c) % 2 === 0;
    case 1: return r % 2 === 0;
    case 2: return c % 3 === 0;
    case 3: return (r + c) % 3 === 0;
    case 4: return (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0;
    case 5: return ((r * c) % 2) + ((r * c) % 3) === 0;
    case 6: return (((r * c) % 2) + ((r * c) % 3)) % 2 === 0;
    default: return (((r + c) % 2) + ((r * c) % 3)) % 2 === 0;
  }
}
function applyMask(m, fn, size, msk) {
  const out = [];
  for (let r = 0; r < size; r++) {
    out.push(m[r].slice());
    for (let c = 0; c < size; c++) if (!fn[r][c] && maskFn(msk, r, c)) out[r][c] ^= 1;
  }
  return out;
}
function placeFormat(mm, size, level, msk) {
  const f = formatBits(level, msk);
  for (let i = 0; i <= 5; i++) mm[8][i] = (f >> (14 - i)) & 1;
  mm[8][7] = (f >> 8) & 1;
  mm[8][8] = (f >> 7) & 1;
  mm[7][8] = (f >> 6) & 1;
  for (let i = 0; i <= 5; i++) mm[i][8] = (f >> i) & 1;
  for (let i = 0; i <= 6; i++) mm[size - 1 - i][8] = (f >> (14 - i)) & 1;
  for (let i = 0; i <= 7; i++) mm[8][size - 1 - i] = (f >> i) & 1;
  mm[size - 8][8] = 1;
}
function penalty(mm, size) {
  let score = 0;
  for (let r = 0; r < size; r++) {
    let run = 1;
    for (let c = 1; c < size; c++) {
      if (mm[r][c] === mm[r][c - 1]) { run++; if (run === 5) score += 3; else if (run > 5) score++; }
      else run = 1;
    }
  }
  for (let c = 0; c < size; c++) {
    let run = 1;
    for (let r = 1; r < size; r++) {
      if (mm[r][c] === mm[r - 1][c]) { run++; if (run === 5) score += 3; else if (run > 5) score++; }
      else run = 1;
    }
  }
  for (let r = 0; r < size - 1; r++) for (let c = 0; c < size - 1; c++) {
    const s = mm[r][c] + mm[r][c + 1] + mm[r + 1][c] + mm[r + 1][c + 1];
    if (s === 0 || s === 4) score += 3;
  }
  const pat = [1,0,1,1,1,0,1,0,0,0,0];
  const match = get => {
    let cnt = 0;
    for (let i = 0; i + 11 <= size; i++) {
      let ok = true, ok2 = true;
      for (let k = 0; k < 11; k++) {
        if (get(i + k) !== pat[k]) ok = false;
        if (get(i + k) !== pat[10 - k]) ok2 = false;
      }
      if (ok) cnt++;
      if (ok2) cnt++;
    }
    return cnt;
  };
  for (let r = 0; r < size; r++) score += 40 * match(x => mm[r][x]);
  for (let c = 0; c < size; c++) score += 40 * match(x => mm[x][c]);
  let dark = 0;
  for (let r = 0; r < size; r++) for (let c = 0; c < size; c++) dark += mm[r][c];
  score += Math.floor(Math.abs(dark * 100 / (size * size) - 50) / 5) * 10;
  return score;
}

export function draw(canvas, text, px = 420) {
  const res = encode(text), quiet = 4, n = res.size + quiet * 2;
  const scale = Math.max(2, Math.floor(px / n));
  canvas.width = canvas.height = n * scale;
  const g = canvas.getContext("2d");
  g.fillStyle = "#fff";
  g.fillRect(0, 0, canvas.width, canvas.height);
  g.fillStyle = "#000";
  for (let y = 0; y < res.size; y++) for (let x = 0; x < res.size; x++)
    if (res.modules[y][x]) g.fillRect((x + quiet) * scale, (y + quiet) * scale, scale, scale);
  return res;
}
'''

FILES["web/js/frames.js"] = r'''// 프레임 정의와 콜라주 그리기.

// 구형 브라우저 보완
if (!CanvasRenderingContext2D.prototype.roundRect) {
  CanvasRenderingContext2D.prototype.roundRect = function (x, y, w, h, r) {
    const rr = Math.min(typeof r === "number" ? r : 0, w / 2, h / 2);
    this.beginPath();
    this.moveTo(x + rr, y);
    this.arcTo(x + w, y, x + w, y + h, rr);
    this.arcTo(x + w, y + h, x, y + h, rr);
    this.arcTo(x, y + h, x, y, rr);
    this.arcTo(x, y, x + w, y, rr);
    this.closePath();
    return this;
  };
}

export const FRAMES = [
  { id: "light", name: "화이트", bg: "#F4F1EC", fg: "#1B1917", sub: "#8B8378", hair: "rgba(0,0,0,.10)" },
  { id: "dark",  name: "다크",   bg: "#111111", fg: "#FFFFFF", sub: "#8C8681", hair: "rgba(255,255,255,.10)" }
];

export const SLOT = { w: 720, h: 540 };   // 사진 한 컷 (4:3)
const MARGIN = 40, GAP = 20, FOOTER = 150;

export function layout() {
  const W = MARGIN * 2 + SLOT.w;
  const H = MARGIN + SLOT.h * 4 + GAP * 3 + FOOTER;
  const slots = [];
  for (let i = 0; i < 4; i++) {
    slots.push({ x: MARGIN, y: MARGIN + i * (SLOT.h + GAP), w: SLOT.w, h: SLOT.h });
  }
  return { W, H, slots, footY: MARGIN + SLOT.h * 4 + GAP * 3 };
}

export function drawCover(g, src, dx, dy, dw, dh, mirror) {
  const sw = src.videoWidth || src.width, sh = src.videoHeight || src.height;
  if (!sw || !sh) return;
  const sr = sw / sh, dr = dw / dh;
  let cw = sw, ch = sh, cx = 0, cy = 0;
  if (sr > dr) { cw = sh * dr; cx = (sw - cw) / 2; }
  else { ch = sw / dr; cy = (sh - ch) / 2; }
  g.save();
  if (mirror) {
    g.translate(dx + dw, dy);
    g.scale(-1, 1);
    g.drawImage(src, cx, cy, cw, ch, 0, 0, dw, dh);
  } else {
    g.drawImage(src, cx, cy, cw, ch, dx, dy, dw, dh);
  }
  g.restore();
}

function stamp() {
  const d = new Date(), p = n => String(n).padStart(2, "0");
  return d.getFullYear() + "." + p(d.getMonth() + 1) + "." + p(d.getDate()) + "  " + p(d.getHours()) + ":" + p(d.getMinutes());
}

export function renderCollage(canvas, frame, photos, scale = 1) {
  const L = layout();
  canvas.width = Math.round(L.W * scale);
  canvas.height = Math.round(L.H * scale);
  const g = canvas.getContext("2d");
  g.setTransform(scale, 0, 0, scale, 0, 0);
  g.imageSmoothingQuality = "high";

  g.fillStyle = frame.bg;
  g.fillRect(0, 0, L.W, L.H);

  L.slots.forEach((s, i) => {
    const p = photos[i];
    if (p) {
      g.drawImage(p, s.x, s.y, s.w, s.h);
      g.strokeStyle = frame.hair;
      g.lineWidth = 1;
      g.strokeRect(s.x + 0.5, s.y + 0.5, s.w - 1, s.h - 1);
    } else {
      g.save();
      g.strokeStyle = frame.sub;
      g.globalAlpha = 0.4;
      g.lineWidth = 3;
      g.setLineDash([14, 10]);
      g.strokeRect(s.x, s.y, s.w, s.h);
      g.setLineDash([]);
      g.globalAlpha = 0.55;
      g.fillStyle = frame.sub;
      g.font = '600 44px "Apple SD Gothic Neo","Malgun Gothic",sans-serif';
      g.textAlign = "center";
      g.textBaseline = "middle";
      g.fillText(String(i + 1), s.x + s.w / 2, s.y + s.h / 2);
      g.restore();
    }
  });

  g.textAlign = "center";
  g.textBaseline = "top";
  g.fillStyle = frame.fg;
  g.font = '800 46px "Apple SD Gothic Neo","Malgun Gothic",sans-serif';
  g.fillText("인생네컷", L.W / 2, L.footY + 40);
  g.fillStyle = frame.sub;
  g.font = '500 26px "Apple SD Gothic Neo","Malgun Gothic",sans-serif';
  g.fillText(stamp(), L.W / 2, L.footY + 98);
  return canvas;
}
'''

FILES["web/js/camera.js"] = r'''// 카메라 열기, 프리뷰 그리기, 한 컷 찍기, 무음 녹화.
import { SLOT, drawCover } from "./frames.js";

const PREVIEW_W = 1200, PREVIEW_H = 900;

const video = document.createElement("video");
video.playsInline = true;
video.muted = true;
video.setAttribute("playsinline", "");
video.style.cssText = "position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none";
document.body.appendChild(video);

export const cam = {
  stream: null,
  canvas: null,
  ctx: null,
  raf: 0,
  overlay: null,
  mirror: true,
  flashAt: -9999,
  recorder: null,
  chunks: [],
  videoExt: "webm",

  async open() {
    this.close();
    this.stream = await navigator.mediaDevices.getUserMedia({
      audio: false,
      video: { facingMode: "user", width: { ideal: 1440 }, height: { ideal: 1080 } }
    });
    this.stream.getAudioTracks().forEach(t => { t.stop(); this.stream.removeTrack(t); });
    video.srcObject = this.stream;
    await video.play();
  },

  close() {
    if (this.stream) {
      this.stream.getTracks().forEach(t => t.stop());
      this.stream = null;
    }
  },

  attach(canvas) {
    this.canvas = canvas;
    canvas.width = PREVIEW_W;
    canvas.height = PREVIEW_H;
    this.ctx = canvas.getContext("2d");
  },

  start(overlayFn) {
    this.overlay = overlayFn || null;
    if (this.raf) return;
    const tick = () => {
      this.raf = requestAnimationFrame(tick);
      const g = this.ctx;
      g.fillStyle = "#000";
      g.fillRect(0, 0, PREVIEW_W, PREVIEW_H);
      if (video.readyState >= 2) drawCover(g, video, 0, 0, PREVIEW_W, PREVIEW_H, this.mirror);
      if (this.overlay) this.overlay(g, PREVIEW_W, PREVIEW_H);
      const since = performance.now() - this.flashAt;
      if (since < 420) {
        g.fillStyle = "rgba(255,255,255," + Math.max(0, 1 - since / 420) * 0.9 + ")";
        g.fillRect(0, 0, PREVIEW_W, PREVIEW_H);
      }
    };
    tick();
  },

  stop() {
    if (this.raf) cancelAnimationFrame(this.raf);
    this.raf = 0;
  },

  flash() { this.flashAt = performance.now(); },

  // 오버레이가 없는 원본에서 한 컷을 뜹니다.
  shot() {
    const c = document.createElement("canvas");
    c.width = SLOT.w * 2;
    c.height = SLOT.h * 2;
    drawCover(c.getContext("2d"), video, 0, 0, c.width, c.height, this.mirror);
    return c;
  },

  startRecording() {
    this.chunks = [];
    const src = this.canvas.captureStream(30);
    const videoOnly = new MediaStream(src.getVideoTracks());   // 오디오 트랙 자체가 없습니다
    const list = ["video/mp4;codecs=avc1.42E01E", "video/mp4", "video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm"];
    const mime = list.find(m => window.MediaRecorder && MediaRecorder.isTypeSupported(m)) || "";
    this.videoExt = mime.includes("mp4") ? "mp4" : "webm";
    try {
      this.recorder = new MediaRecorder(videoOnly, mime ? { mimeType: mime, videoBitsPerSecond: 3500000 } : undefined);
    } catch (e) {
      this.recorder = new MediaRecorder(videoOnly);
    }
    this.recorder.ondataavailable = e => { if (e.data && e.data.size) this.chunks.push(e.data); };
    this.recorder.start(1000);
  },

  stopRecording() {
    return new Promise(resolve => {
      const r = this.recorder;
      if (!r || r.state === "inactive") return resolve(null);
      r.onstop = () => resolve(new Blob(this.chunks, { type: this.chunks[0] ? this.chunks[0].type : "video/webm" }));
      r.stop();
    });
  },

  cancelRecording() {
    if (this.recorder && this.recorder.state !== "inactive") {
      this.recorder.onstop = null;
      this.recorder.stop();
    }
    this.chunks = [];
  }
};
'''

FILES["web/js/api.js"] = r'''// 서버와 주고받는 부분.
export async function createSession(imageBlob, videoBlob, videoExt) {
  const fd = new FormData();
  fd.append("image", imageBlob, "photo.png");
  if (videoBlob) fd.append("video", videoBlob, "video." + videoExt);
  const res = await fetch("/api/sessions", { method: "POST", body: fd });
  if (!res.ok) {
    const detail = await res.text();
    throw new Error("업로드 실패 (" + res.status + ") " + detail.slice(0, 120));
  }
  return res.json();
}
'''

FILES["web/js/booth.js"] = r'''// 화면 흐름 제어. 프레임 -> 촬영 -> 고르기 -> 받기.
import { FRAMES, renderCollage } from "./frames.js";
import { cam } from "./camera.js";
import { createSession } from "./api.js";
import * as QR from "./qr.js";

const CFG = { shots: 6, keep: 4, interval: 10 };
const $ = s => document.querySelector(s);

const state = { frame: FRAMES[0], shots: [], picked: [], phase: "idle", nextAt: 0 };

/* ---- 1080x1920 무대를 화면에 맞춰 축소 ---- */
function fitStage() {
  const s = Math.min(window.innerWidth / 1080, window.innerHeight / 1920);
  document.documentElement.style.setProperty("--s", s);
}
window.addEventListener("resize", fitStage);
fitStage();

/* ---- 화면 전환 ---- */
function go(id, step) {
  document.querySelectorAll(".screen").forEach(el => el.classList.remove("is-on"));
  $("#" + id).classList.add("is-on");
  document.querySelectorAll("#steps li").forEach(li => {
    const n = Number(li.dataset.s);
    li.classList.toggle("on", n === step);
    li.classList.toggle("done", n < step);
  });
}

/* ---- 1. 프레임 ---- */
function buildRack() {
  const rack = $("#rack");
  rack.innerHTML = "";
  FRAMES.forEach(f => {
    const b = document.createElement("button");
    b.className = "pick";
    b.type = "button";
    b.setAttribute("aria-pressed", String(f.id === state.frame.id));
    const c = document.createElement("canvas");
    renderCollage(c, f, [], 0.44);
    const nm = document.createElement("span");
    nm.className = "name";
    nm.textContent = f.name;
    b.append(c, nm);
    b.onclick = () => { state.frame = f; buildRack(); };
    rack.appendChild(b);
  });
}

/* ---- 2. 촬영 ---- */
function drawOverlay(g, W, H) {
  if (state.phase !== "shooting") return;
  const left = Math.max(0, state.nextAt - performance.now());
  const secs = Math.ceil(left / 1000);
  const big = left <= 3200;

  g.save();
  g.textAlign = "center";
  g.textBaseline = "middle";
  g.shadowColor = "rgba(0,0,0,.55)";
  g.shadowBlur = 26;
  g.fillStyle = big ? "#FFD23F" : "rgba(255,255,255,.94)";
  const size = big ? 240 + (1 - (left % 1000) / 1000) * 30 : 120;
  g.font = "800 " + size + 'px "Apple SD Gothic Neo","Malgun Gothic",sans-serif';
  g.fillText(String(secs), W / 2, H / 2);
  g.restore();

  g.save();
  g.fillStyle = "rgba(0,0,0,.45)";
  g.beginPath();
  g.roundRect(34, 34, 190, 66, 33);
  g.fill();
  g.fillStyle = "#fff";
  g.textAlign = "left";
  g.textBaseline = "middle";
  g.font = '700 34px "Apple SD Gothic Neo","Malgun Gothic",sans-serif';
  g.fillText(Math.min(state.shots.length + 1, CFG.shots) + " / " + CFG.shots, 64, 68);
  g.restore();

  if (left <= 0) capture();
}

function paintFilmstrip() {
  const box = $("#filmstrip");
  box.innerHTML = "";
  for (let i = 0; i < CFG.shots; i++) {
    const d = document.createElement("div");
    d.className = "cell";
    if (state.shots[i]) {
      d.classList.add("filled");
      const im = new Image();
      im.src = state.shots[i].toDataURL("image/jpeg", 0.8);
      im.alt = "";
      d.appendChild(im);
    } else {
      d.textContent = String(i + 1);
    }
    box.appendChild(d);
  }
}

function capture() {
  state.shots.push(cam.shot());
  cam.flash();
  paintFilmstrip();
  $("#shoot-status").textContent = state.shots.length + "번째 컷";
  if (state.shots.length >= CFG.shots) finishShooting();
  else state.nextAt = performance.now() + CFG.interval * 1000;
}

function startShooting() {
  state.shots = [];
  state.picked = [];
  state.phase = "shooting";
  state.nextAt = performance.now() + CFG.interval * 1000;
  paintFilmstrip();
  cam.startRecording();
  $("#start-shoot").disabled = true;
  $("#shoot-head").textContent = "찍는 중이에요";
  $("#shoot-lede").textContent = CFG.interval + "초마다 한 장씩, 모두 " + CFG.shots + "장입니다.";
}

async function finishShooting() {
  state.phase = "wrapping";
  $("#shoot-status").textContent = "촬영을 마쳤습니다";
  await new Promise(r => setTimeout(r, 1200));      // 마지막 컷도 영상에 남기고
  state.videoBlob = await cam.stopRecording();
  cam.stop();
  cam.close();
  buildChooser();
  go("s-choose", 3);
}

/* ---- 3. 고르기 ---- */
function buildChooser() {
  const box = $("#thumbs");
  box.innerHTML = "";
  state.shots.forEach((c, i) => {
    const b = document.createElement("button");
    b.className = "thumb";
    b.type = "button";
    b.setAttribute("aria-pressed", "false");
    b.setAttribute("aria-label", (i + 1) + "번 사진");
    const im = new Image();
    im.src = c.toDataURL("image/jpeg", 0.88);
    im.alt = "";
    const badge = document.createElement("span");
    badge.className = "badge";
    badge.textContent = String(i + 1);
    b.append(im, badge);
    b.onclick = () => toggle(i);
    box.appendChild(b);
  });
  updateChooser();
}

function toggle(i) {
  const at = state.picked.indexOf(i);
  if (at >= 0) state.picked.splice(at, 1);
  else if (state.picked.length < CFG.keep) state.picked.push(i);
  updateChooser();
}

function updateChooser() {
  Array.from($("#thumbs").children).forEach((b, i) => {
    const at = state.picked.indexOf(i);
    b.setAttribute("aria-pressed", String(at >= 0));
    b.querySelector(".badge").textContent = at >= 0 ? String(at + 1) : String(i + 1);
  });
  $("#pick-count").textContent = String(state.picked.length);
  $("#finish").disabled = state.picked.length !== CFG.keep;
  renderCollage($("#live-collage"), state.frame, state.picked.map(i => state.shots[i]), 0.375);
}

/* ---- 4. 결과 ---- */
async function buildResult() {
  const c = document.createElement("canvas");
  renderCollage(c, state.frame, state.picked.map(i => state.shots[i]));
  const blob = await new Promise(r => c.toBlob(r, "image/png"));
  $("#final-img").src = URL.createObjectURL(blob);
  $("#qr-note").textContent = "주소를 만드는 중";
  go("s-done", 4);

  try {
    const data = await createSession(blob, state.videoBlob, cam.videoExt);
    QR.draw($("#qr"), data.url, 420);
    $("#qr-note").textContent = "휴대폰 카메라로 QR을 찍으세요";
  } catch (e) {
    $("#qr-note").textContent = e.message;
  }
}

/* ---- 카메라 오류 안내 ---- */
function showCamError(err) {
  const map = {
    NotAllowedError: "카메라 권한이 막혀 있습니다. 브라우저 주소창에서 카메라를 허용해 주세요.",
    NotFoundError: "연결된 카메라가 없습니다. 카메라를 연결하고 다시 눌러 주세요.",
    NotReadableError: "다른 앱이 카메라를 쓰고 있습니다. 그 앱을 닫고 다시 눌러 주세요."
  };
  const box = $("#camerr");
  box.textContent = map[err.name] || ("카메라를 열지 못했습니다: " + err.message);
  box.hidden = false;
}

async function enterShoot() {
  $("#camerr").hidden = true;
  try {
    await cam.open();
    cam.attach($("#preview"));
    cam.start(drawOverlay);
    state.phase = "idle";
    state.shots = [];
    paintFilmstrip();
    $("#shoot-head").textContent = "준비되면 시작을 누르세요";
    $("#shoot-lede").textContent = "시작하면 녹화가 켜지고 " + CFG.interval + "초 뒤 첫 장이 찍힙니다.";
    $("#shoot-status").textContent = "";
    $("#start-shoot").disabled = false;
    go("s-shoot", 2);
  } catch (err) {
    showCamError(err);
    go("s-frame", 1);
  }
}

function reset() {
  state.shots = [];
  state.picked = [];
  state.videoBlob = null;
  state.phase = "idle";
  cam.cancelRecording();
  cam.stop();
  cam.close();
}

/* ---- 배선 ---- */
buildRack();
$("#go-shoot").onclick = enterShoot;
$("#start-shoot").onclick = startShooting;
$("#abort").onclick = () => { reset(); go("s-frame", 1); };
$("#finish").onclick = buildResult;
$("#reshoot").onclick = () => { reset(); enterShoot(); };
$("#again").onclick = () => { reset(); go("s-frame", 1); };
window.addEventListener("beforeunload", () => cam.close());
go("s-frame", 1);
'''

# =====================================================================
# 부속 파일
# =====================================================================

FILES["run.sh"] = r'''#!/usr/bin/env bash
cd "$(dirname "$0")"
if [ -x .venv/bin/python ]; then
  exec .venv/bin/python -m app.main
else
  exec python3 -m app.main
fi
'''

FILES["run.bat"] = r'''@echo off
cd /d %~dp0
if exist .venv\Scripts\python.exe (
  .venv\Scripts\python.exe -m app.main
) else (
  python -m app.main
)
'''

FILES[".gitignore"] = """.venv/
data/
__pycache__/
*.pyc
"""

FILES["README.md"] = r'''# 인생네컷 부스

FastAPI 서버 + 순수 JS 프론트엔드. 1080x1920 세로 화면에 맞춰 그려집니다.

## 실행

    ./run.sh          # 윈도우는 run.bat

- 촬영 화면: <http://localhost:8000>
- QR이 가리키는 주소: 실행할 때 콘솔에 찍히는 랜 IP 주소

카메라는 보안 컨텍스트에서만 열립니다. 부스 PC는 `localhost`로 접속하면 되고,
휴대폰은 카메라를 쓰지 않으니 랜 IP(http)로 다운로드 페이지만 열면 됩니다.

## 흐름

프레임 고르기 -> 10초 간격 6장 촬영(무음 녹화) -> 4장 고르기 -> 결과 + QR

## 구조

    app/            FastAPI 서버
      config.py     경로, 포트, 공개 주소
      storage.py    세션 폴더 저장과 만료 정리
      routes.py     업로드 API, 다운로드 페이지
      main.py       진입점
    web/            정적 프론트엔드
      index.html    부스 화면
      download.html QR로 열리는 휴대폰 페이지
      css/app.css
      js/booth.js   화면 흐름
      js/camera.js  카메라, 프리뷰, 녹화
      js/frames.js  프레임 정의와 콜라주 렌더
      js/qr.js      QR 인코더
      js/api.js     업로드 호출
    data/           촬영 결과 (자동 생성, 자동 만료)

## 설정

환경변수로 조절합니다.

    BOOTH_PORT=8000                # 포트
    BOOTH_PUBLIC_URL=https://...   # QR에 넣을 주소를 직접 지정
    BOOTH_RETENTION_HOURS=6        # 결과물 보관 시간
    BOOTH_MAX_UPLOAD_MB=120        # 업로드 1건 최대 용량

촬영 장수와 간격은 `web/js/booth.js` 맨 위 `CFG`에서 바꿉니다.

## 알아둘 것

- 휴대폰이 QR을 열려면 부스 PC와 같은 와이파이에 있어야 하고, PC 방화벽에서 해당 포트가 열려 있어야 합니다.
- 녹화 포맷은 브라우저가 지원하는 순서로 mp4 또는 webm이 선택됩니다.
- 촬영 중에는 탭을 백그라운드로 보내지 마세요. 카운트다운이 `requestAnimationFrame` 기준이라 멈춥니다.
'''


def write_files(root: Path) -> None:
    for rel, body in FILES.items():
        path = root / rel
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(body, encoding="utf-8")
        if rel.endswith(".sh"):
            os.chmod(path, 0o755)
    (root / "data").mkdir(exist_ok=True)


def setup_venv(root: Path):
    venv_dir = root / ".venv"
    print("가상환경을 만드는 중: {}".format(venv_dir))
    try:
        subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], check=True)
    except subprocess.CalledProcessError:
        print("! 가상환경 생성에 실패했습니다. --no-venv 로 다시 실행한 뒤 직접 설치해 주세요.")
        return None
    py = venv_dir / ("Scripts" if os.name == "nt" else "bin") / ("python.exe" if os.name == "nt" else "python")
    print("의존성을 설치하는 중")
    subprocess.run([str(py), "-m", "pip", "install", "--quiet", "--upgrade", "pip"], check=False)
    try:
        subprocess.run([str(py), "-m", "pip", "install", "--quiet", "-r", str(root / "requirements.txt")], check=True)
    except subprocess.CalledProcessError:
        print("! 의존성 설치에 실패했습니다. 네트워크를 확인하고 아래를 직접 실행해 주세요.")
        print("  {} -m pip install -r requirements.txt".format(py))
        return None
    return py


def main() -> None:
    args = [a for a in sys.argv[1:] if not a.startswith("-")]
    no_venv = "--no-venv" in sys.argv
    root = Path(args[0] if args else "life4cut").expanduser().resolve()

    print("설치 위치: {}".format(root))
    root.mkdir(parents=True, exist_ok=True)
    write_files(root)
    print("파일 {}개를 만들었습니다.".format(len(FILES)))

    py = None if no_venv else setup_venv(root)

    print("")
    print("준비됐습니다.")
    print("  cd {}".format(root))
    if py:
        print("  ./run.sh" if os.name != "nt" else "  run.bat")
    else:
        print("  python3 -m pip install -r requirements.txt")
        print("  python3 -m app.main")
    print("")
    print("촬영 화면은 http://localhost:8000 으로 여세요.")


if __name__ == "__main__":
    main()
