#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
3D Flipbook Viewer (Gradio) – 전체 소스
최종 수정: 2025-05-18
"""
# ────────────────────────────
# 기본 모듈
# ────────────────────────────
import os
import shutil
import uuid
import json
import logging
import traceback
from pathlib import Path
# 외부 라이브러리
import gradio as gr
from PIL import Image
import fitz # PyMuPDF
# ────────────────────────────
# 로깅 설정
# ────────────────────────────
logging.basicConfig(
level=logging.INFO, # 필요하면 DEBUG
format="%(asctime)s [%(levelname)s] %(message)s",
filename="app.log", # 동일 디렉터리에 로그 파일 생성
filemode="a",
)
logging.info("🚀 Flipbook app started")
# ────────────────────────────
# 상수 / 경로
# ────────────────────────────
TEMP_DIR = "temp"
UPLOAD_DIR = os.path.join(TEMP_DIR, "uploads")
OUTPUT_DIR = os.path.join(TEMP_DIR, "output")
THUMBS_DIR = os.path.join(OUTPUT_DIR, "thumbs")
HTML_DIR = os.path.join("public", "flipbooks") # 웹으로 노출되는 위치
# 디렉터리 보장
for d in [TEMP_DIR, UPLOAD_DIR, OUTPUT_DIR, THUMBS_DIR, HTML_DIR]:
os.makedirs(d, exist_ok=True)
# ────────────────────────────
# 유틸 함수
# ────────────────────────────
def create_thumbnail(src: str, dst: str, size=(300, 300)) -> str | None:
"""원본 이미지를 썸네일로 저장"""
try:
with Image.open(src) as im:
im.thumbnail(size, Image.LANCZOS)
im.save(dst)
return dst
except Exception as e:
logging.error("Thumbnail error: %s", e)
return None
# ────────────────────────────
# PDF → 이미지
# ────────────────────────────
def process_pdf(pdf_path: str, session_id: str) -> list[dict]:
pages_info = []
out_dir = os.path.join(OUTPUT_DIR, session_id)
th_dir = os.path.join(THUMBS_DIR, session_id)
os.makedirs(out_dir, exist_ok=True)
os.makedirs(th_dir, exist_ok=True)
try:
pdf_doc = fitz.open(pdf_path)
for idx, page in enumerate(pdf_doc):
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2× 해상도
img_path = os.path.join(out_dir, f"page_{idx+1}.png")
pix.save(img_path)
thumb_path = os.path.join(th_dir, f"thumb_{idx+1}.png")
create_thumbnail(img_path, thumb_path)
html_overlay = (
"""
인터랙티브 플립북 예제
이 페이지는 인터랙티브 컨텐츠 기능을 보여줍니다.
"""
if idx == 0 else None
)
pages_info.append(
{
"src": f"./temp/output/{session_id}/page_{idx+1}.png",
"thumb": f"./temp/output/thumbs/{session_id}/thumb_{idx+1}.png",
"title": f"페이지 {idx+1}",
"htmlContent": html_overlay,
}
)
logging.info("PDF page %d → %s", idx + 1, img_path)
return pages_info
except Exception as e:
logging.error("process_pdf() failed: %s", e)
return []
# ────────────────────────────
# 이미지 업로드 처리
# ────────────────────────────
def process_images(img_paths: list[str], session_id: str) -> list[dict]:
pages_info = []
out_dir = os.path.join(OUTPUT_DIR, session_id)
th_dir = os.path.join(THUMBS_DIR, session_id)
os.makedirs(out_dir, exist_ok=True)
os.makedirs(th_dir, exist_ok=True)
for i, src in enumerate(img_paths):
try:
dst = os.path.join(out_dir, f"image_{i+1}.png")
shutil.copy(src, dst)
thumb = os.path.join(th_dir, f"thumb_{i+1}.png")
create_thumbnail(src, thumb)
if i == 0:
html_overlay = """
이미지 갤러리
갤러리의 첫 번째 이미지입니다.
"""
elif i == 1:
html_overlay = """
두 번째 이미지
페이지 모서리를 드래그해 넘겨보세요.
"""
else:
html_overlay = None
pages_info.append(
{
"src": f"./temp/output/{session_id}/image_{i+1}.png",
"thumb": f"./temp/output/thumbs/{session_id}/thumb_{i+1}.png",
"title": f"이미지 {i+1}",
"htmlContent": html_overlay,
}
)
logging.info("Image %d copied → %s", i + 1, dst)
except Exception as e:
logging.error("process_images() error (%s): %s", src, e)
return pages_info
# ────────────────────────────
# 플립북 HTML 생성
# ────────────────────────────
def generate_flipbook_html(
pages_info: list[dict], session_id: str, view_mode: str, skin: str
) -> str:
# None 값은 JSON 직렬화 전에 제거
for p in pages_info:
if p.get("htmlContent") is None:
p.pop("htmlContent", None)
pages_json = json.dumps(pages_info, ensure_ascii=False)
html_file = f"flipbook_{session_id}.html"
html_path = os.path.join(HTML_DIR, html_file)
html = f"""
3D Flipbook
"""
Path(html_path).write_text(html, encoding="utf-8")
public_url = f"/public/flipbooks/{html_file}"
# 사용자에게 돌려줄 링크 덩어리
return f"""
플립북이 준비되었습니다!
버튼을 눌러 새 창에서 확인하세요.
플립북 열기
"""
# ────────────────────────────
# 콜백: PDF 업로드
# ────────────────────────────
def create_flipbook_from_pdf(
pdf_file: gr.File | None, view_mode="2d", skin="light"
):
session_id = str(uuid.uuid4())
debug: list[str] = []
if not pdf_file:
return (
"PDF 파일을 업로드하세요.
",
"No file",
)
try:
pdf_path = pdf_file.name
debug.append(f"PDF path: {pdf_path}")
pages_info = process_pdf(pdf_path, session_id)
debug.append(f"Extracted pages: {len(pages_info)}")
if not pages_info:
raise RuntimeError("PDF 처리 결과가 비어 있습니다.")
html_block = generate_flipbook_html(
pages_info, session_id, view_mode, skin
)
return html_block, "\n".join(debug)
except Exception as e:
tb = traceback.format_exc()
logging.error(tb)
debug.extend(["❌ ERROR ↓↓↓", tb])
return (
f"오류: {e}
",
"\n".join(debug),
)
# ────────────────────────────
# 콜백: 이미지 업로드
# ────────────────────────────
def create_flipbook_from_images(
images: list[gr.File] | None, view_mode="2d", skin="light"
):
session_id = str(uuid.uuid4())
debug: list[str] = []
if not images:
return (
"이미지를 하나 이상 업로드하세요.
",
"No images",
)
try:
img_paths = [f.name for f in images]
debug.append(f"Images: {img_paths}")
pages_info = process_images(img_paths, session_id)
debug.append(f"Processed: {len(pages_info)}")
if not pages_info:
raise RuntimeError("이미지 처리 실패")
html_block = generate_flipbook_html(
pages_info, session_id, view_mode, skin
)
return html_block, "\n".join(debug)
except Exception as e:
tb = traceback.format_exc()
logging.error(tb)
debug.extend(["❌ ERROR ↓↓↓", tb])
return (
f"오류: {e}
",
"\n".join(debug),
)
# ────────────────────────────
# Gradio UI
# ────────────────────────────
with gr.Blocks(title="3D Flipbook Viewer") as demo:
gr.Markdown("# 3D Flipbook Viewer\nPDF 또는 이미지를 업로드해 인터랙티브 플립북을 만드세요.")
with gr.Tabs():
# PDF 탭
with gr.TabItem("PDF 업로드"):
pdf_file = gr.File(label="PDF 파일", file_types=[".pdf"])
with gr.Accordion("고급 설정", open=False):
pdf_view = gr.Radio(
["webgl", "3d", "2d", "swipe"],
value="2d",
label="뷰 모드",
)
pdf_skin = gr.Radio(
["light", "dark", "gradient"],
value="light",
label="스킨",
)
pdf_btn = gr.Button("PDF → 플립북", variant="primary")
pdf_out = gr.HTML()
pdf_dbg = gr.Textbox(label="디버그", lines=10)
pdf_btn.click(
create_flipbook_from_pdf,
inputs=[pdf_file, pdf_view, pdf_skin],
outputs=[pdf_out, pdf_dbg],
)
# 이미지 탭
with gr.TabItem("이미지 업로드"):
imgs = gr.File(
label="이미지 파일들",
file_types=["image"],
file_count="multiple",
)
with gr.Accordion("고급 설정", open=False):
img_view = gr.Radio(
["webgl", "3d", "2d", "swipe"],
value="2d",
label="뷰 모드",
)
img_skin = gr.Radio(
["light", "dark", "gradient"],
value="light",
label="스킨",
)
img_btn = gr.Button("이미지 → 플립북", variant="primary")
img_out = gr.HTML()
img_dbg = gr.Textbox(label="디버그", lines=10)
img_btn.click(
create_flipbook_from_images,
inputs=[imgs, img_view, img_skin],
outputs=[img_out, img_dbg],
)
gr.Markdown(
"### 사용법\n"
"1. PDF 또는 이미지 탭을 선택하고 파일을 업로드합니다.\n"
"2. 필요하면 뷰 모드/스킨을 바꿉니다.\n"
"3. ‘플립북’ 버튼을 누르면 결과가 아래 뜹니다."
)
# ────────────────────────────
# 실행
# ────────────────────────────
if __name__ == \"__main__\":
demo.launch(debug=True) # share=True 필요 시 추가