ginipick commited on
Commit
8ac3a87
Β·
verified Β·
1 Parent(s): fac3caa

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +90 -413
app.py CHANGED
@@ -1,436 +1,113 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- """
4
- 3D Flipbook Viewer (Gradio) – μ΅œμ’… μˆ˜μ • 버전
5
- μ΅œμ’… μˆ˜μ •: 2025-05-18
6
-
7
- - f-string κ΅¬λ¬Έμ—μ„œ JS의 { }λ₯Ό {{ }}둜 μ΄μŠ€μΌ€μ΄ν”„ 처리
8
- - Python 3.9 μ΄ν•˜ ν˜Έν™˜(typing.Optional, typing.List)
9
- - Gradioκ°€ μƒμ„±ν•œ μž„μ‹œ 파일 β†’ temp/uploads ν΄λ”λ‘œ 볡사 ν›„ 처리
10
- - /public 폴더에 μ΅œμ’… HTML 생성 (정적 μ„œλΉ™ 별도 μ„€μ • ν•„μš”)
11
- """
12
-
13
- import os
14
- import shutil
15
- import uuid
16
- import json
17
- import logging
18
- import traceback
19
- from pathlib import Path
20
- from typing import Optional, List, Dict
21
-
22
- import gradio as gr
23
- from PIL import Image
24
- import fitz # PyMuPDF
25
-
26
- # ────────────────────────────
27
- # λ‘œκΉ… μ„€μ •
28
- # ────────────────────────────
29
- logging.basicConfig(
30
- level=logging.INFO,
31
- format="%(asctime)s [%(levelname)s] %(message)s",
32
- filename="app.log",
33
- filemode="a",
34
- )
35
- logging.info("πŸš€ Flipbook app started")
36
-
37
- # ────────────────────────────
38
- # 폴더 경둜 μ„€μ •
39
- # ────────────────────────────
40
- TEMP_DIR = "temp"
41
- UPLOAD_DIR = os.path.join(TEMP_DIR, "uploads")
42
- OUTPUT_DIR = os.path.join(TEMP_DIR, "output")
43
- THUMBS_DIR = os.path.join(OUTPUT_DIR, "thumbs")
44
- HTML_DIR = os.path.join("public", "flipbooks")
45
-
46
- # 폴더 생성
47
- for d in [TEMP_DIR, UPLOAD_DIR, OUTPUT_DIR, THUMBS_DIR, HTML_DIR]:
48
- os.makedirs(d, exist_ok=True)
49
-
50
- # ────────────────────────────
51
- # μœ ν‹Έ ν•¨μˆ˜: 이미지 썸넀일
52
- # ────────────────────────────
53
- def create_thumbnail(src: str, dst: str, size=(300, 300)) -> Optional[str]:
54
- """원본 이미지λ₯Ό μΈλ„€μΌλ‘œ μ €μž₯ (이미지 μ—΄κΈ° μ‹€νŒ¨ μ‹œ None 리턴)"""
55
- try:
56
- with Image.open(src) as im:
57
- im.thumbnail(size, Image.LANCZOS)
58
- im.save(dst)
59
- return dst
60
- except Exception as e:
61
- logging.error("Thumbnail error: %s", e)
62
- return None
63
-
64
- # ────────────────────────────
65
- # PDF 처리 ν•¨μˆ˜: PDF β†’ 이미지
66
- # ────────────────────────────
67
- def process_pdf(pdf_path: str, session_id: str) -> List[Dict]:
68
- """PDF νŒŒμΌμ„ νŽ˜μ΄μ§€ 별 PNG둜 λ³€ν™˜ν•˜κ³ , νŽ˜μ΄μ§€ 정보 리슀트 λ°˜ν™˜"""
69
- pages_info = []
70
- out_dir = os.path.join(OUTPUT_DIR, session_id)
71
- th_dir = os.path.join(THUMBS_DIR, session_id)
72
- os.makedirs(out_dir, exist_ok=True)
73
- os.makedirs(th_dir, exist_ok=True)
74
-
75
- try:
76
- pdf_doc = fitz.open(pdf_path)
77
- for idx, page in enumerate(pdf_doc):
78
- # 해상도(1.5λ°° 정도) - ν•„μš”μ‹œ 쑰절
79
- mat = fitz.Matrix(1.5, 1.5)
80
- pix = page.get_pixmap(matrix=mat)
81
- img_path = os.path.join(out_dir, f"page_{idx+1}.png")
82
- pix.save(img_path)
83
-
84
- thumb_path = os.path.join(th_dir, f"thumb_{idx+1}.png")
85
- create_thumbnail(img_path, thumb_path)
86
-
87
- # 첫 νŽ˜μ΄μ§€ μ˜ˆμ‹œλ‘œ μ˜€λ²„λ ˆμ΄ HTML
88
- html_overlay = (
89
- """
90
- <div style="position:absolute;top:50px;left:50px;
91
- background:rgba(255,255,255,.7);padding:10px;
92
- border-radius:5px;">
93
- <div style="font-size:18px;font-weight:bold;color:#333;">
94
- μΈν„°λž™ν‹°λΈŒ ν”Œλ¦½λΆ 예제
95
- </div>
96
- <div style="margin-top:5px;color:#666;">
97
- 이 νŽ˜μ΄μ§€λŠ” μΈν„°λž™ν‹°λΈŒ 컨텐츠 κΈ°λŠ₯을 λ³΄μ—¬μ€λ‹ˆλ‹€.
98
- </div>
99
- </div>
100
- """
101
- if idx == 0 else None
102
- )
103
-
104
- pages_info.append(
105
- {
106
- "src": f"./temp/output/{session_id}/page_{idx+1}.png",
107
- "thumb": f"./temp/output/thumbs/{session_id}/thumb_{idx+1}.png",
108
- "title": f"νŽ˜μ΄μ§€ {idx+1}",
109
- "htmlContent": html_overlay,
110
- }
111
- )
112
- logging.info("PDF page %d β†’ %s", idx + 1, img_path)
113
- return pages_info
114
 
115
- except Exception as e:
116
- logging.error("process_pdf() failed: %s", e)
117
- return []
 
118
 
119
- # ────────────────────────────
120
- # 이미지 처리 ν•¨μˆ˜
121
- # ────────────────────────────
122
- def process_images(img_paths: List[str], session_id: str) -> List[Dict]:
123
- """이미지듀을 temp/output으둜 볡사, 썸넀일 생성, νŽ˜μ΄μ§€ 정보 λ°˜ν™˜"""
124
- pages_info = []
125
- out_dir = os.path.join(OUTPUT_DIR, session_id)
126
- th_dir = os.path.join(THUMBS_DIR, session_id)
127
- os.makedirs(out_dir, exist_ok=True)
128
- os.makedirs(th_dir, exist_ok=True)
129
 
130
- for i, src in enumerate(img_paths):
131
- try:
132
- dst = os.path.join(out_dir, f"image_{i+1}.png")
133
- shutil.copy(src, dst)
134
 
135
- thumb = os.path.join(th_dir, f"thumb_{i+1}.png")
136
- create_thumbnail(dst, thumb)
137
 
138
- # νŽ˜μ΄μ§€λ³„ μ˜€λ²„λ ˆμ΄ μ˜ˆμ‹œ
139
- if i == 0:
140
- html_overlay = """
141
- <div style="position:absolute;top:50px;left:50px;
142
- background:rgba(255,255,255,.7);padding:10px;
143
- border-radius:5px;">
144
- <div style="font-size:18px;font-weight:bold;color:#333;">
145
- 이미지 가러리
146
- </div>
147
- <div style="margin-top:5px;color:#666;">
148
- 가러리의 첫 번째 μ΄λ―Έμ§€μž…λ‹ˆλ‹€.
149
- </div>
150
- </div>
151
- """
152
- elif i == 1:
153
- html_overlay = """
154
- <div style="position:absolute;top:50px;left:50px;
155
- background:rgba(255,255,255,.7);padding:10px;
156
- border-radius:5px;">
157
- <div style="font-size:18px;font-weight:bold;color:#333;">
158
- 두 번째 이미지
159
- </div>
160
- <div style="margin-top:5px;color:#666;">
161
- νŽ˜μ΄μ§€ λͺ¨μ„œλ¦¬λ₯Ό λ“œλž˜κ·Έν•΄ λ„˜κ²¨λ³΄μ„Έμš”.
162
- </div>
163
- </div>
164
- """
165
- else:
166
- html_overlay = None
167
-
168
- pages_info.append(
169
- {
170
- "src": f"./temp/output/{session_id}/image_{i+1}.png",
171
- "thumb": f"./temp/output/thumbs/{session_id}/thumb_{i+1}.png",
172
- "title": f"이미지 {i+1}",
173
- "htmlContent": html_overlay,
174
- }
175
- )
176
- logging.info("Image %d copied β†’ %s", i + 1, dst)
177
-
178
- except Exception as e:
179
- logging.error("process_images() error (%s): %s", src, e)
180
-
181
- return pages_info
182
-
183
- # ────────────────────────────
184
- # ν”Œλ¦½λΆ HTML 생성
185
- # ────────────────────────────
186
- def generate_flipbook_html(
187
- pages_info: List[Dict],
188
- session_id: str,
189
- view_mode: str,
190
- skin: str
191
- ) -> str:
192
- """
193
- 3D Flipbook 용 HTML 파일 생성 ν›„, HTML 링크(λ²„νŠΌ) 블둝을 λ°˜ν™˜
194
- - f-string μ•ˆμ—μ„œ JS의 { }λŠ” {{ }}둜 μ΄μŠ€μΌ€μ΄ν”„
195
- """
196
- # htmlContent=None 제거
197
- for p in pages_info:
198
- if p.get("htmlContent") is None:
199
- p.pop("htmlContent", None)
200
-
201
- pages_json = json.dumps(pages_info, ensure_ascii=False)
202
- html_file = f"flipbook_{session_id}.html"
203
- html_path = os.path.join(HTML_DIR, html_file)
204
-
205
- html = f"""
206
  <!DOCTYPE html>
207
  <html lang="ko">
208
  <head>
209
  <meta charset="UTF-8">
210
- <meta name="viewport" content="width=device-width,initial-scale=1">
211
- <title>3D Flipbook</title>
 
212
 
213
- <!-- 3D Flipbook κ΄€λ ¨ CSS/JS -->
214
- <link rel="stylesheet" href="/public/libs/flipbook/css/flipbook.style.css">
215
- <script src="/public/libs/flipbook/js/flipbook.min.js"></script>
216
- <script src="/public/libs/flipbook/js/flipbook.webgl.min.js"></script>
 
 
 
 
 
 
 
217
 
218
  <style>
219
- html,body{{margin:0;height:100%;overflow:hidden}}
220
- #flipbook-container{{position:absolute;inset:0}}
221
- .loading{{position:absolute;top:50%;left:50%;
222
- transform:translate(-50%,-50%);text-align:center;font-family:sans-serif}}
223
- .spinner{{width:50px;height:50px;border:5px solid #f3f3f3;
224
- border-top:5px solid #3498db;border-radius:50%;
225
- animation:spin 1s linear infinite;margin:0 auto 20px}}
226
- @keyframes spin{{0%{{transform:rotate(0)}}100%{{transform:rotate(360deg)}}}}
 
 
 
227
  </style>
228
  </head>
229
  <body>
230
- <div id="flipbook-container"></div>
231
- <div id="loading" class="loading">
232
- <div class="spinner"></div>
233
- <div>ν”Œλ¦½λΆ λ‘œλ”© 쀑...</div>
 
 
 
 
234
  </div>
235
 
236
  <script>
237
- document.addEventListener('DOMContentLoaded', () => {{
238
- const hide = () => {{
239
- document.getElementById('loading').style.display = 'none';
240
- }};
241
-
242
- try {{
243
- const options = {{
244
- pages: {pages_json},
245
- viewMode: "{view_mode}",
246
- skin: "{skin}",
247
- responsiveView: true,
248
- singlePageMode: false,
249
- singlePageModeIfMobile: true,
250
- pageFlipDuration: 1,
251
- thumbnailsOnStart: true,
252
- btnThumbs: {{ enabled: true }},
253
- btnPrint: {{ enabled: true }},
254
- btnDownloadPages: {{ enabled: true }},
255
- btnDownloadPdf: {{ enabled: true }},
256
- btnShare: {{ enabled: true }},
257
- btnSound: {{ enabled: true }},
258
- btnExpand: {{ enabled: true }}
259
- }};
260
- new FlipBook(document.getElementById('flipbook-container'), options);
261
- setTimeout(hide, 1000);
262
- }} catch(e) {{
263
- console.error(e);
264
- alert('ν”Œλ¦½λΆ μ΄ˆκΈ°ν™” 였λ₯˜:' + e.message);
265
- }}
266
- }});
 
 
 
 
 
 
 
 
 
267
  </script>
268
  </body>
269
  </html>
270
  """
271
 
272
- Path(html_path).write_text(html, encoding="utf-8")
273
- public_url = f"/public/flipbooks/{html_file}"
274
-
275
- # μ‚¬μš©μžμ—κ²Œ λŒλ €μ€„ HTML 블둝
276
- return f"""
277
- <div style="text-align:center;padding:20px;background:#f9f9f9;border-radius:5px">
278
- <h2 style="margin:0;color:#333">ν”Œλ¦½λΆμ΄ μ€€λΉ„λ˜μ—ˆμŠ΅λ‹ˆλ‹€!</h2>
279
- <p style="margin:15px 0">λ²„νŠΌμ„ 눌러 μƒˆ μ°½μ—μ„œ ν™•μΈν•˜μ„Έμš”.</p>
280
- <a href="{public_url}" target="_blank"
281
- style="display:inline-block;background:#4caf50;color:#fff;
282
- padding:12px 24px;border-radius:4px;font-weight:bold;font-size:16px">
283
- ν”Œλ¦½λΆ μ—΄κΈ°
284
- </a>
285
- </div>
286
- """
287
-
288
- # ────────────────────────────
289
- # PDF μ—…λ‘œλ“œ 콜백
290
- # ────────────────────────────
291
- def create_flipbook_from_pdf(
292
- pdf_file: Optional[gr.File],
293
- view_mode: str = "2d",
294
- skin: str = "light"
295
- ):
296
- session_id = str(uuid.uuid4())
297
- debug: List[str] = []
298
-
299
- if not pdf_file:
300
- return (
301
- "<div style='color:red;padding:20px;'>PDF νŒŒμΌμ„ μ—…λ‘œλ“œν•˜μ„Έμš”.</div>",
302
- "No file",
303
- )
304
-
305
- try:
306
- # Gradio μž„μ‹œ 경둜
307
- uploaded_temp_path = pdf_file.name
308
-
309
- # temp/uploads 폴더에 볡사
310
- filename_only = os.path.basename(uploaded_temp_path)
311
- pdf_path = os.path.join(UPLOAD_DIR, filename_only)
312
- shutil.copyfile(uploaded_temp_path, pdf_path)
313
-
314
- debug.append(f"Copied PDF to: {pdf_path}")
315
-
316
- # PDF 처리
317
- pages_info = process_pdf(pdf_path, session_id)
318
- debug.append(f"Extracted pages: {len(pages_info)}")
319
-
320
- if not pages_info:
321
- raise RuntimeError("PDF 처리 κ²°κ³Όκ°€ λΉ„μ–΄ μžˆμŠ΅λ‹ˆλ‹€.")
322
-
323
- # ν”Œλ¦½λΆ HTML
324
- html_block = generate_flipbook_html(pages_info, session_id, view_mode, skin)
325
- return html_block, "\n".join(debug)
326
-
327
- except Exception as e:
328
- tb = traceback.format_exc()
329
- logging.error(tb)
330
- debug.extend(["❌ ERROR ↓↓↓", tb])
331
- return (
332
- f"<div style='color:red;padding:20px;'>였λ₯˜: {e}</div>",
333
- "\n".join(debug),
334
- )
335
-
336
- # ────────────────────────────
337
- # 이미지 μ—…λ‘œλ“œ 콜백
338
- # ────────────────────────────
339
- def create_flipbook_from_images(
340
- images: Optional[List[gr.File]],
341
- view_mode: str = "2d",
342
- skin: str = "light"
343
- ):
344
- session_id = str(uuid.uuid4())
345
- debug: List[str] = []
346
-
347
- if not images:
348
- return (
349
- "<div style='color:red;padding:20px;'>이미지λ₯Ό ν•˜λ‚˜ 이상 μ—…λ‘œλ“œν•˜μ„Έμš”.</div>",
350
- "No images",
351
- )
352
-
353
- try:
354
- # μž„μ‹œ 이미지 κ²½λ‘œλ“€
355
- img_paths = []
356
- for fobj in images:
357
- uploaded_temp_path = fobj.name
358
- filename_only = os.path.basename(uploaded_temp_path)
359
- local_img_path = os.path.join(UPLOAD_DIR, filename_only)
360
- shutil.copyfile(uploaded_temp_path, local_img_path)
361
- img_paths.append(local_img_path)
362
-
363
- debug.append(f"Images: {img_paths}")
364
-
365
- # 이미지 처리
366
- pages_info = process_images(img_paths, session_id)
367
- debug.append(f"Processed: {len(pages_info)}")
368
-
369
- if not pages_info:
370
- raise RuntimeError("이미지 처리 μ‹€νŒ¨")
371
-
372
- # ν”Œλ¦½λΆ HTML
373
- html_block = generate_flipbook_html(pages_info, session_id, view_mode, skin)
374
- return html_block, "\n".join(debug)
375
-
376
- except Exception as e:
377
- tb = traceback.format_exc()
378
- logging.error(tb)
379
- debug.extend(["❌ ERROR ↓↓↓", tb])
380
- return (
381
- f"<div style='color:red;padding:20px;'>였λ₯˜: {e}</div>",
382
- "\n".join(debug),
383
- )
384
-
385
- # ────────────────────────────
386
- # Gradio UI
387
- # ────────────────────────────
388
- with gr.Blocks(title="3D Flipbook Viewer") as demo:
389
- gr.Markdown("# 3D Flipbook Viewer\nPDF λ˜λŠ” 이미지λ₯Ό μ—…λ‘œλ“œν•΄ μΈν„°λž™ν‹°λΈŒ ν”Œλ¦½λΆμ„ λ§Œλ“œμ„Έμš”.")
390
-
391
- with gr.Tabs():
392
- # PDF νƒ­
393
- with gr.TabItem("PDF μ—…λ‘œλ“œ"):
394
- pdf_file = gr.File(label="PDF 파일", file_types=[".pdf"])
395
- with gr.Accordion("κ³ κΈ‰ μ„€μ •", open=False):
396
- pdf_view = gr.Radio(["webgl", "3d", "2d", "swipe"], value="2d", label="λ·° λͺ¨λ“œ")
397
- pdf_skin = gr.Radio(["light", "dark", "gradient"], value="light", label="μŠ€ν‚¨")
398
- pdf_btn = gr.Button("PDF β†’ ν”Œλ¦½λΆ", variant="primary")
399
- pdf_out = gr.HTML()
400
- pdf_dbg = gr.Textbox(label="디버그", lines=10)
401
-
402
- pdf_btn.click(
403
- create_flipbook_from_pdf,
404
- inputs=[pdf_file, pdf_view, pdf_skin],
405
- outputs=[pdf_out, pdf_dbg],
406
- )
407
-
408
- # 이미지 νƒ­
409
- with gr.TabItem("이미지 μ—…λ‘œλ“œ"):
410
- imgs = gr.File(label="이미지 νŒŒμΌλ“€", file_types=["image"], file_count="multiple")
411
- with gr.Accordion("κ³ κΈ‰ μ„€μ •", open=False):
412
- img_view = gr.Radio(["webgl", "3d", "2d", "swipe"], value="2d", label="λ·° λͺ¨λ“œ")
413
- img_skin = gr.Radio(["light", "dark", "gradient"], value="light", label="μŠ€ν‚¨")
414
- img_btn = gr.Button("이미지 β†’ ν”Œλ¦½λΆ", variant="primary")
415
- img_out = gr.HTML()
416
- img_dbg = gr.Textbox(label="디버그", lines=10)
417
-
418
- img_btn.click(
419
- create_flipbook_from_images,
420
- inputs=[imgs, img_view, img_skin],
421
- outputs=[img_out, img_dbg],
422
- )
423
-
424
- gr.Markdown(
425
- "### μ‚¬μš©λ²•\n"
426
- "1. PDF λ˜λŠ” 이미지 탭을 μ„ νƒν•˜κ³  νŒŒμΌμ„ μ—…λ‘œλ“œν•©λ‹ˆλ‹€.\n"
427
- "2. ν•„μš”ν•˜λ©΄ λ·° λͺ¨λ“œ/μŠ€ν‚¨μ„ λ°”κΏ‰λ‹ˆλ‹€.\n"
428
- "3. β€˜ν”Œλ¦½λΆβ€™ λ²„νŠΌμ„ λˆ„λ₯΄λ©΄ κ²°κ³Όκ°€ μ•„λž˜ λœΉλ‹ˆλ‹€."
429
- )
430
-
431
- # ────────────────────────────
432
- # μ‹€ν–‰
433
- # ────────────────────────────
434
- if __name__ == "__main__":
435
- # share=True λ“± μ˜΅μ…˜μ„ λ„£μ–΄ 배포/곡유 κ°€λŠ₯
436
- demo.launch(debug=True)
 
1
+ # app.py
2
+ #
3
+ # Hugging Face Spaces ➜ Build type: "FastAPI" (Python)
4
+ # μ‹€ν–‰ μ‹œ http://<space-url>/ 둜 μ ‘μ†ν•˜λ©΄ FlipBook UIκ°€ ν‘œμ‹œλ©λ‹ˆλ‹€.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
+ from fastapi import FastAPI
7
+ from fastapi.responses import HTMLResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+ import pathlib
10
 
11
+ BASE = pathlib.Path(__file__).parent
 
 
 
 
 
 
 
 
 
12
 
13
+ app = FastAPI()
 
 
 
14
 
15
+ # ── 1. 같은 ν΄λ”μ˜ 정적 파일(js / css / img / mp3 λ“±)을 /static 경둜둜 μ„œλΉ™
16
+ app.mount("/static", StaticFiles(directory=BASE), name="static")
17
 
18
+ # ── 2. index.html μ†ŒμŠ€ (script/link 경둜만 /static/ 둜 λ°”κΏˆ)
19
+ INDEX_HTML = """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  <!DOCTYPE html>
21
  <html lang="ko">
22
  <head>
23
  <meta charset="UTF-8">
24
+ <title>FlipBook – μ—…λ‘œλ“œ + λ‚΄μž₯ μ‚¬μš΄λ“œ</title>
25
+
26
+ <link rel="stylesheet" href="/static/flipbook.css">
27
 
28
+ <!-- ν•„μˆ˜ JS μˆœμ„œ -->
29
+ <script src="/static/three.js"></script>
30
+ <script src="/static/iscroll.js"></script>
31
+ <script src="/static/mark.js"></script>
32
+ <script src="/static/mod3d.js"></script>
33
+
34
+ <script src="/static/flipbook.js"></script>
35
+ <script src="/static/flipbook.book3.js"></script>
36
+ <script src="/static/flipbook.scroll.js"></script>
37
+ <script src="/static/flipbook.swipe.js"></script>
38
+ <script src="/static/flipbook.webgl.js"></script>
39
 
40
  <style>
41
+ body{margin:0;font-family:sans-serif;background:#f4f4f4}
42
+ h1{text-align:center;margin:24px 0}
43
+ #viewer{width:900px;height:600px;margin:0 auto 40px;background:#fff;border:1px solid #ccc}
44
+ .upload-wrapper{display:flex;justify-content:center}
45
+ #uploadBtn{
46
+ all:unset;width:44px;height:44px;line-height:44px;text-align:center;
47
+ font-size:26px;border-radius:50%;cursor:pointer;
48
+ background:#ffb84d;color:#fff;box-shadow:0 2px 4px rgba(0,0,0,.2);transition:.2s;
49
+ }
50
+ #uploadBtn:hover{transform:translateY(-2px);box-shadow:0 4px 8px rgba(0,0,0,.25)}
51
+ #fileInput{display:none}
52
  </style>
53
  </head>
54
  <body>
55
+
56
+ <h1>Real3D FlipBook – 이미지 μ—…λ‘œλ“œ + μ‚¬μš΄λ“œ ✨</h1>
57
+
58
+ <div id="viewer"></div>
59
+
60
+ <div class="upload-wrapper">
61
+ <button id="uploadBtn" title="이미지 μ—…λ‘œλ“œ">πŸ“·</button>
62
+ <input id="fileInput" type="file" accept="image/*" multiple />
63
  </div>
64
 
65
  <script>
66
+ let book=null;
67
+ const holder=document.getElementById('viewer');
68
+
69
+ document.getElementById('uploadBtn').onclick=()=>document.getElementById('fileInput').click();
70
+
71
+ document.getElementById('fileInput').onchange=e=>{
72
+ if(!e.target.files.length) return;
73
+ const files=[...e.target.files];
74
+ const pages=[];
75
+ let done=0;
76
+ files.forEach((f,i)=>{
77
+ const rd=new FileReader();
78
+ rd.onload=ev=>{
79
+ pages[i]={src:ev.target.result,thumb:ev.target.result};
80
+ if(++done===files.length) createBook(pages);
81
+ };
82
+ rd.readAsDataURL(f);
83
+ });
84
+ };
85
+
86
+ function createBook(pages){
87
+ if(book){ book.destroy(); holder.innerHTML=''; }
88
+
89
+ book=new FlipBook(holder,{
90
+ pages,
91
+ viewMode:'webgl',
92
+ autoSize:true,
93
+ flipDuration:800,
94
+ backgroundColor:'#ffffff',
95
+
96
+ sound:true,
97
+ assets:{
98
+ flipMp3:'/static/turnPage2.mp3',
99
+ hardFlipMp3:'/static/turnPage2.mp3'
100
+ },
101
+
102
+ controlsProps:{enableFullscreen:true,thumbnails:true}
103
+ });
104
+ }
105
  </script>
106
  </body>
107
  </html>
108
  """
109
 
110
+ # ── 3. λΌμš°ν„°: GET / β†’ index.html λ°˜ν™˜
111
+ @app.get("/", response_class=HTMLResponse)
112
+ async def root():
113
+ return INDEX_HTML