thumbnail-maker 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of thumbnail-maker might be problematic. Click here for more details.

@@ -0,0 +1,544 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ 썸네일 렌더러 - DSL 기반 이미지 생성
5
+ """
6
+
7
+ from PIL import Image, ImageDraw, ImageFont, ImageFilter
8
+ import json
9
+ import re
10
+ import io
11
+ from typing import Dict, List, Tuple, Optional
12
+ import os
13
+ import pathlib
14
+
15
+ import requests
16
+ from fontTools.ttLib import TTFont
17
+ try:
18
+ import woff2 # from pywoff2
19
+ except Exception:
20
+ woff2 = None
21
+ try:
22
+ # Optional: WOFF -> OTF 변환기
23
+ from woff2otf import woff2otf
24
+ except Exception:
25
+ woff2otf = None
26
+
27
+
28
+ def sanitize(name: str) -> str:
29
+ """파일명 안전화"""
30
+ return re.sub(r'[^a-zA-Z0-9\-_]', '_', name)
31
+
32
+
33
+ class ThumbnailRenderer:
34
+ """썸네일 렌더러 클래스"""
35
+
36
+ # 해상도 프리셋 매핑
37
+ RESOLUTIONS = {
38
+ '16:9': (480, 270),
39
+ '9:16': (270, 480),
40
+ '4:3': (480, 360),
41
+ '1:1': (360, 360)
42
+ }
43
+
44
+ MARGIN = 20 # 여백
45
+ LINE_HEIGHT = 1.1 # 줄간격 배수
46
+ DEFAULT_OUTLINE_THICKNESS = 4 # 기본 외곽선 두께
47
+
48
+ @staticmethod
49
+ def get_resolution(dsl_resolution: Dict) -> Tuple[int, int]:
50
+ """해상도 계산"""
51
+ if not dsl_resolution or 'type' not in dsl_resolution:
52
+ return ThumbnailRenderer.RESOLUTIONS['16:9']
53
+
54
+ res_type = dsl_resolution['type']
55
+
56
+ if res_type == 'preset':
57
+ value = dsl_resolution.get('value', '16:9')
58
+ return ThumbnailRenderer.RESOLUTIONS.get(value, ThumbnailRenderer.RESOLUTIONS['16:9'])
59
+
60
+ elif res_type == 'custom':
61
+ w = int(dsl_resolution.get('width', 480))
62
+ h = int(dsl_resolution.get('height', 270))
63
+ return (w, h) if w > 0 and h > 0 else ThumbnailRenderer.RESOLUTIONS['16:9']
64
+
65
+ elif res_type == 'fixedRatio':
66
+ ratio_str = dsl_resolution.get('ratioValue', '16:9')
67
+ parts = ratio_str.split(':')
68
+ if len(parts) != 2:
69
+ return ThumbnailRenderer.RESOLUTIONS['16:9']
70
+
71
+ try:
72
+ rw, rh = float(parts[0]), float(parts[1])
73
+ if rw <= 0 or rh <= 0:
74
+ return ThumbnailRenderer.RESOLUTIONS['16:9']
75
+
76
+ aspect_ratio = rw / rh
77
+
78
+ if 'width' in dsl_resolution and dsl_resolution['width'] is not None:
79
+ width = int(dsl_resolution['width'])
80
+ if width > 0:
81
+ height = int(width / aspect_ratio)
82
+ return (width, height)
83
+
84
+ if 'height' in dsl_resolution and dsl_resolution['height'] is not None:
85
+ height = int(dsl_resolution['height'])
86
+ if height > 0:
87
+ width = int(height * aspect_ratio)
88
+ return (width, height)
89
+ except ValueError:
90
+ pass
91
+
92
+ return ThumbnailRenderer.RESOLUTIONS['16:9']
93
+
94
+ return ThumbnailRenderer.RESOLUTIONS['16:9']
95
+
96
+ @staticmethod
97
+ def parse_font_faces(texts: List[Dict]) -> List[Dict]:
98
+ """텍스트에서 폰트 페이스 추출"""
99
+ font_faces = []
100
+ seen = set()
101
+
102
+ for txt in texts:
103
+ if 'font' in txt and 'faces' in txt['font']:
104
+ for face in txt['font']['faces']:
105
+ key = f"{face.get('name')}|{face.get('url')}|{face.get('weight')}|{face.get('style')}"
106
+ if key not in seen:
107
+ seen.add(key)
108
+ font_faces.append(face)
109
+
110
+ return font_faces
111
+
112
+ # ---------- 폰트 유틸 ----------
113
+ @staticmethod
114
+ def _fonts_dir() -> str:
115
+ d = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'fonts')
116
+ return os.path.normpath(d)
117
+
118
+ @staticmethod
119
+ def _font_safe_filename(face: Dict) -> str:
120
+ url_path = ''
121
+ try:
122
+ # url의 경로 확장자 추출 시 실패 대비
123
+ from urllib.parse import urlparse
124
+ url_path = urlparse(face.get('url', '')).path
125
+ except Exception:
126
+ pass
127
+ ext = os.path.splitext(url_path)[1].lower() or '.ttf'
128
+ name = sanitize(face.get('name', 'Font'))
129
+ weight = sanitize(str(face.get('weight', 'normal')))
130
+ style = sanitize(str(face.get('style', 'normal')))
131
+ return f"{name}-{weight}-{style}{ext}"
132
+
133
+ @staticmethod
134
+ def _font_ttf_filename(face: Dict) -> str:
135
+ name = sanitize(face.get('name', 'Font'))
136
+ weight = sanitize(str(face.get('weight', 'normal')))
137
+ style = sanitize(str(face.get('style', 'normal')))
138
+ return f"{name}-{weight}-{style}.ttf"
139
+
140
+ @staticmethod
141
+ def _download(url: str, dest_path: str) -> None:
142
+ os.makedirs(os.path.dirname(dest_path), exist_ok=True)
143
+ r = requests.get(url, timeout=30)
144
+ r.raise_for_status()
145
+ with open(dest_path, 'wb') as f:
146
+ f.write(r.content)
147
+
148
+ @staticmethod
149
+ def _convert_woff_to_ttf(woff_path: str, ttf_path: str) -> None:
150
+ # 우선 woff -> otf 변환이 가능하면 사용
151
+ if woff2otf is not None:
152
+ otf_path = os.path.splitext(ttf_path)[0] + '.otf'
153
+ with open(woff_path, 'rb') as rf:
154
+ woff_bytes = rf.read()
155
+ otf_bytes = woff2otf(woff_bytes)
156
+ with open(otf_path, 'wb') as wf:
157
+ wf.write(otf_bytes)
158
+ return
159
+ # 폴백: fontTools를 사용한 시도 (환경에 따라 실패할 수 있음)
160
+ font = TTFont(woff_path)
161
+ font.flavor = None
162
+ font.save(ttf_path)
163
+
164
+ @staticmethod
165
+ def _convert_woff2_to_ttf(woff2_path: str, ttf_path: str) -> None:
166
+ if woff2 is None:
167
+ raise RuntimeError("pywoff2 모듈이 필요합니다 (requirements.txt 참고)")
168
+ # pywoff2는 파일 경로 기반 변환 지원
169
+ # 파일 내용을 직접 디코딩해서 저장하는 방식으로 처리
170
+ with open(woff2_path, 'rb') as f:
171
+ data = f.read()
172
+ decompressed = woff2.decompress(data)
173
+ with open(ttf_path, 'wb') as f:
174
+ f.write(decompressed)
175
+
176
+ @staticmethod
177
+ def ensure_fonts(texts: List[Dict]) -> None:
178
+ """DSL 내 faces를 다운로드/변환하여 Pillow가 읽을 수 있는 TTF로 보장"""
179
+ faces = ThumbnailRenderer.parse_font_faces(texts)
180
+ if not faces:
181
+ return
182
+ fonts_dir = ThumbnailRenderer._fonts_dir()
183
+ os.makedirs(fonts_dir, exist_ok=True)
184
+
185
+ for face in faces:
186
+ url = face.get('url')
187
+ if not url:
188
+ continue
189
+ original_name = ThumbnailRenderer._font_safe_filename(face)
190
+ original_path = os.path.join(fonts_dir, original_name)
191
+ ttf_name = ThumbnailRenderer._font_ttf_filename(face)
192
+ ttf_path = os.path.join(fonts_dir, ttf_name)
193
+ otf_path = os.path.splitext(ttf_path)[0] + '.otf'
194
+
195
+ # 이미 TTF가 있으면 스킵
196
+ if os.path.exists(ttf_path) or os.path.exists(otf_path):
197
+ continue
198
+
199
+ # 로컬 파일 또는 원격 URL 구분
200
+ from urllib.parse import urlparse
201
+ parsed = urlparse(url)
202
+ is_local = False
203
+ local_path = ''
204
+ if parsed.scheme == 'file':
205
+ is_local = True
206
+ local_path = os.path.abspath(parsed.path)
207
+ elif os.path.isabs(url) and os.path.exists(url):
208
+ is_local = True
209
+ local_path = os.path.abspath(url)
210
+
211
+ if is_local:
212
+ source_path = local_path
213
+ ext = pathlib.Path(source_path).suffix.lower()
214
+ else:
215
+ # 원격: 원본 없으면 다운로드
216
+ ext = pathlib.Path(original_name).suffix.lower()
217
+ if not os.path.exists(original_path):
218
+ try:
219
+ ThumbnailRenderer._download(url, original_path)
220
+ except Exception as e:
221
+ print(f"폰트 다운로드 실패: {url} -> {e}")
222
+ continue
223
+ source_path = original_path
224
+
225
+ # 확장자별 변환/복사
226
+ try:
227
+ if ext == '.ttf' or ext == '.otf':
228
+ if source_path != ttf_path:
229
+ # 확장자 유지 복사
230
+ target = ttf_path if ext == '.ttf' else otf_path
231
+ with open(source_path, 'rb') as rf, open(target, 'wb') as wf:
232
+ wf.write(rf.read())
233
+ elif ext == '.woff2':
234
+ ThumbnailRenderer._convert_woff2_to_ttf(source_path, ttf_path)
235
+ elif ext == '.woff':
236
+ ThumbnailRenderer._convert_woff_to_ttf(source_path, ttf_path)
237
+ else:
238
+ # 미지원 확장자는 시도만 해보고 실패시 스킵
239
+ try:
240
+ ThumbnailRenderer._convert_woff_to_ttf(source_path, ttf_path)
241
+ except Exception:
242
+ pass
243
+ except Exception as e:
244
+ print(f"폰트 변환 실패: {source_path} -> {ttf_path}, {e}")
245
+
246
+ @staticmethod
247
+ def split_lines(text: str) -> List[str]:
248
+ """텍스트를 줄 단위로 분리"""
249
+ return text.split('\n')
250
+
251
+ @staticmethod
252
+ def load_font(font_path: str, size: int, weight: str = 'normal', style: str = 'normal') -> ImageFont.FreeTypeFont:
253
+ """폰트 로드"""
254
+ try:
255
+ font = ImageFont.truetype(font_path, size)
256
+ return font
257
+ except Exception as e:
258
+ print(f"폰트 로드 실패: {font_path}, {e}")
259
+ try:
260
+ return ImageFont.load_default()
261
+ except:
262
+ return ImageFont.load_default()
263
+
264
+ @staticmethod
265
+ def get_text_dimensions(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.FreeTypeFont) -> Tuple[int, int]:
266
+ """텍스트 크기 측정"""
267
+ bbox = draw.textbbox((0, 0), text, font=font)
268
+ return (bbox[2] - bbox[0], bbox[3] - bbox[1])
269
+
270
+ @staticmethod
271
+ def draw_text_with_outline(
272
+ draw: ImageDraw.ImageDraw,
273
+ text: str,
274
+ position: Tuple[int, int],
275
+ font: ImageFont.FreeTypeFont,
276
+ fill: str,
277
+ outline: Optional[Dict] = None
278
+ ):
279
+ """외곽선과 함께 텍스트 그리기"""
280
+ x, y = position
281
+
282
+ # 외곽선 그리기
283
+ if outline and outline.get('color') and outline.get('thickness', 0) > 0:
284
+ thickness = outline['thickness']
285
+ outline_color = outline['color']
286
+
287
+ # text-shadow 효과를 위/아래/좌/우로 여러 번 그리기
288
+ for dx in range(-thickness, thickness + 1):
289
+ for dy in range(-thickness, thickness + 1):
290
+ draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
291
+
292
+ # 메인 텍스트 그리기
293
+ draw.text((x, y), text, font=font, fill=fill)
294
+
295
+ @staticmethod
296
+ def render_background(
297
+ img: Image.Image,
298
+ bg_config: Dict,
299
+ width: int,
300
+ height: int
301
+ ):
302
+ """배경 렌더링"""
303
+ bg_type = bg_config.get('type', 'solid')
304
+
305
+ if bg_type == 'solid':
306
+ color = bg_config.get('color', '#ffffff')
307
+ fill = Image.new('RGB', (width, height), color)
308
+ img.paste(fill)
309
+
310
+ elif bg_type == 'gradient':
311
+ colors = bg_config.get('colors', ['#ffffff', '#000000'])
312
+ gradient = Image.new('RGB', (width, height), colors[0])
313
+
314
+ # 간단한 수평 그라디언트
315
+ for i in range(width):
316
+ ratio = i / width
317
+ r1, g1, b1 = [int(c) for c in (colors[0][1:3], colors[0][3:5], colors[0][5:7])]
318
+ r2, g2, b2 = [int(c) for c in (colors[-1][1:3], colors[-1][3:5], colors[-1][5:7])]
319
+
320
+ r = int(r1 + (r2 - r1) * ratio)
321
+ g = int(g1 + (g2 - g1) * ratio)
322
+ b = int(b1 + (b2 - b1) * ratio)
323
+
324
+ for j in range(height):
325
+ gradient.putpixel((i, j), (r, g, b))
326
+
327
+ img.paste(gradient)
328
+
329
+ elif bg_type == 'image':
330
+ img_path = bg_config.get('imagePath', '')
331
+
332
+ # base64 데이터 URL 처리
333
+ if img_path.startswith('data:image'):
334
+ # base64 디코딩 처리
335
+ import base64
336
+ header, encoded = img_path.split(',', 1)
337
+ image_data = base64.b64decode(encoded)
338
+ bg_img = Image.open(io.BytesIO(image_data))
339
+ else:
340
+ if not os.path.exists(img_path):
341
+ print(f"배경 이미지를 찾을 수 없음: {img_path}")
342
+ return
343
+
344
+ bg_img = Image.open(img_path)
345
+
346
+ # cover 알고리즘으로 리사이즈
347
+ img_ratio = bg_img.width / bg_img.height
348
+ canvas_ratio = width / height
349
+
350
+ if img_ratio > canvas_ratio:
351
+ # 이미지가 더 넓음: 높이 기준으로 맞춤
352
+ new_height = height
353
+ new_width = int(height * img_ratio)
354
+ bg_img = bg_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
355
+ left = (new_width - width) // 2
356
+ bg_img = bg_img.crop((left, 0, left + width, height))
357
+ else:
358
+ # 이미지가 더 높음: 너비 기준으로 맞춤
359
+ new_width = width
360
+ new_height = int(width / img_ratio)
361
+ bg_img = bg_img.resize((new_width, new_height), Image.Resampling.LANCZOS)
362
+ top = (new_height - height) // 2
363
+ bg_img = bg_img.crop((0, top, width, top + height))
364
+
365
+ # 블러 효과 적용
366
+ image_blur = bg_config.get('imageBlur', 0)
367
+ if image_blur > 0:
368
+ bg_img = bg_img.filter(ImageFilter.GaussianBlur(radius=image_blur))
369
+
370
+ # 투명도 적용
371
+ image_opacity = bg_config.get('imageOpacity', 1.0)
372
+ if image_opacity < 1.0 and bg_img.mode in ('RGBA', 'LA'):
373
+ alpha = bg_img.split()[-1]
374
+ alpha = alpha.point(lambda p: int(p * image_opacity))
375
+ bg_img.putalpha(alpha)
376
+ elif image_opacity < 1.0:
377
+ bg_img = bg_img.convert('RGBA')
378
+ alpha = bg_img.split()[-1]
379
+ alpha = alpha.point(lambda p: int(p * image_opacity))
380
+ bg_img.putalpha(alpha)
381
+
382
+ # 배경 위에 붙이기
383
+ if bg_img.mode == 'RGBA':
384
+ img.paste(bg_img, (0, 0), bg_img)
385
+ else:
386
+ img.paste(bg_img, (0, 0))
387
+
388
+ @staticmethod
389
+ def render_thumbnail(dsl: Dict, output_path: str):
390
+ """DSL을 읽어서 썸네일 생성"""
391
+ thumbnail_config = dsl.get('Thumbnail', {})
392
+
393
+ # 해상도 결정
394
+ resolution = ThumbnailRenderer.get_resolution(thumbnail_config.get('Resolution', {}))
395
+ width, height = resolution
396
+
397
+ # 이미지 생성
398
+ img = Image.new('RGB', (width, height), '#ffffff')
399
+
400
+ # 배경 렌더링
401
+ if 'Background' in thumbnail_config:
402
+ ThumbnailRenderer.render_background(img, thumbnail_config['Background'], width, height)
403
+
404
+ # 텍스트 렌더링
405
+ if 'Texts' in thumbnail_config:
406
+ draw = ImageDraw.Draw(img)
407
+
408
+ for txt_config in thumbnail_config['Texts']:
409
+ if not txt_config.get('enabled', True):
410
+ continue
411
+
412
+ # 기본값 설정
413
+ content = txt_config.get('content', '')
414
+ fontSize = txt_config.get('fontSize', 48)
415
+ fontFamily = txt_config.get('font', {}).get('name', 'Arial')
416
+ color = txt_config.get('color', '#000000')
417
+ gridPosition = txt_config.get('gridPosition', 'tl')
418
+ fontWeight = txt_config.get('fontWeight', 'normal')
419
+ fontStyle = txt_config.get('fontStyle', 'normal')
420
+ lineHeight = txt_config.get('lineHeight', ThumbnailRenderer.LINE_HEIGHT)
421
+ wordWrap = txt_config.get('wordWrap', False)
422
+ outline = txt_config.get('outline')
423
+
424
+ # 외곽선 기본값
425
+ if outline and (not outline.get('thickness') or outline.get('thickness') < 0):
426
+ outline['thickness'] = ThumbnailRenderer.DEFAULT_OUTLINE_THICKNESS
427
+
428
+ # faces 기반 폰트 확보 (필요 시 다운로드/변환)
429
+ try:
430
+ ThumbnailRenderer.ensure_fonts(thumbnail_config.get('Texts', []))
431
+ except Exception as e:
432
+ print(f"폰트 확보 과정 경고: {e}")
433
+
434
+ # 확보된 TTF 경로 우선 시도
435
+ fonts_dir = ThumbnailRenderer._fonts_dir()
436
+ base_name = f"{sanitize(fontFamily)}-{sanitize(str(fontWeight))}-{sanitize(str(fontStyle))}"
437
+ ttf_candidate = os.path.join(fonts_dir, base_name + '.ttf')
438
+ otf_candidate = os.path.join(fonts_dir, base_name + '.otf')
439
+
440
+ # 로컬 정적 폰트 폴더(프로젝트 루트/fonts)도 탐색
441
+ legacy_ttf = os.path.join('fonts', f"{sanitize(fontFamily)}-{fontWeight}-{fontStyle}.ttf")
442
+ legacy_woff = os.path.join('fonts', f"{sanitize(fontFamily)}-{fontWeight}-{fontStyle}.woff")
443
+ font_path = None
444
+ if os.path.exists(ttf_candidate):
445
+ font_path = ttf_candidate
446
+ elif os.path.exists(otf_candidate):
447
+ font_path = otf_candidate
448
+ elif os.path.exists(legacy_ttf):
449
+ font_path = legacy_ttf
450
+ elif os.path.exists(legacy_woff):
451
+ # 가능한 경우 변환 시도 후 사용
452
+ try:
453
+ os.makedirs(fonts_dir, exist_ok=True)
454
+ conv_target_ttf = os.path.join(fonts_dir, base_name + '.ttf')
455
+ conv_target_otf = os.path.join(fonts_dir, base_name + '.otf')
456
+ if os.path.splitext(legacy_woff)[1].lower() == '.woff':
457
+ ThumbnailRenderer._convert_woff_to_ttf(legacy_woff, conv_target_ttf)
458
+ font_path = (
459
+ conv_target_ttf if os.path.exists(conv_target_ttf)
460
+ else (conv_target_otf if os.path.exists(conv_target_otf) else None)
461
+ )
462
+ except Exception:
463
+ font_path = None
464
+
465
+ # 폰트 로드 + 한글 폴백
466
+ font = None
467
+ if font_path and os.path.exists(font_path):
468
+ font = ThumbnailRenderer.load_font(font_path, fontSize)
469
+ if font is None:
470
+ # Windows 한글 폴백 (맑은 고딕)
471
+ for fallback in [
472
+ os.path.join(os.environ.get('WINDIR', 'C:\\Windows'), 'Fonts', 'malgun.ttf'),
473
+ os.path.join(os.environ.get('WINDIR', 'C:\\Windows'), 'Fonts', 'malgunsl.ttf'),
474
+ ]:
475
+ try:
476
+ if os.path.exists(fallback):
477
+ font = ImageFont.truetype(fallback, fontSize)
478
+ break
479
+ except Exception:
480
+ pass
481
+ if font is None:
482
+ try:
483
+ font = ImageFont.truetype("arial.ttf", fontSize)
484
+ except Exception:
485
+ font = ImageFont.load_default()
486
+
487
+ # 줄 분리
488
+ lines = ThumbnailRenderer.split_lines(content)
489
+
490
+ # 라인 높이 계산
491
+ lh = int(fontSize * lineHeight)
492
+ totalTextHeight = len(lines) * lh
493
+
494
+ # 그리드 위치 결정
495
+ row = gridPosition[0] if len(gridPosition) > 0 else 't' # t, m, b
496
+ col = gridPosition[1] if len(gridPosition) > 1 else 'l' # l, c, r
497
+
498
+ # X 위치 결정
499
+ if col == 'l':
500
+ targetX = ThumbnailRenderer.MARGIN
501
+ textAlign = 'left'
502
+ elif col == 'c':
503
+ targetX = width // 2
504
+ textAlign = 'center'
505
+ else: # 'r'
506
+ targetX = width - ThumbnailRenderer.MARGIN
507
+ textAlign = 'right'
508
+
509
+ # Y 위치 결정
510
+ if row == 't':
511
+ baseY = ThumbnailRenderer.MARGIN
512
+ elif row == 'm':
513
+ baseY = (height // 2) - (totalTextHeight // 2)
514
+ else: # 'b'
515
+ baseY = height - ThumbnailRenderer.MARGIN - totalTextHeight
516
+
517
+ # 텍스트 그리기
518
+ for line_idx, line in enumerate(lines):
519
+ currentY = baseY + (line_idx * lh)
520
+
521
+ # 텍스트 크기 측정
522
+ bbox = draw.textbbox((0, 0), line, font=font)
523
+ textWidth = bbox[2] - bbox[0]
524
+
525
+ # 정렬에 따른 X 위치 조정
526
+ x = targetX
527
+ if textAlign == 'center':
528
+ x = targetX - textWidth // 2
529
+ elif textAlign == 'right':
530
+ x = targetX - textWidth
531
+
532
+ # 텍스트 그리기
533
+ if outline and outline.get('color') and outline.get('thickness', 0) > 0:
534
+ ThumbnailRenderer.draw_text_with_outline(
535
+ draw, line, (x, currentY), font, color, outline
536
+ )
537
+ else:
538
+ draw.text((x, currentY), line, font=font, fill=color)
539
+
540
+ # 저장
541
+ img.save(output_path, 'PNG')
542
+ print(f"[OK] 썸네일 생성 완료: {output_path}")
543
+
544
+
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: thumbnail-maker
3
+ Version: 0.1.1
4
+ Summary: 썸네일 생성 도구 - Pillow와 PySide6 기반
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: fonttools>=4.47.0
7
+ Requires-Dist: pillow>=10.0.0
8
+ Requires-Dist: pyside6>=6.10.0
9
+ Requires-Dist: requests>=2.31.0
10
+ Requires-Dist: uv-easy>=0.2.5
11
+ Description-Content-Type: text/markdown
12
+
13
+ # 썸네일 생성기 (Python)
14
+
15
+ JavaScript 기반 썸네일 생성기를 Python으로 변환한 프로젝트입니다.
16
+
17
+ ## 주요 변경사항
18
+
19
+ - **Pillow**: 이미지 생성 라이브러리로 사용
20
+ - **PySide6**: GUI 프레임워크로 사용
21
+ - **Python**: 모든 코드를 Python으로 변환
22
+
23
+ ## 설치 방법
24
+
25
+ ```bash
26
+ pip install -r requirements.txt
27
+ ```
28
+
29
+ ## 사용 방법
30
+
31
+ uv를 사용하여 설치:
32
+
33
+ ```bash
34
+ uv sync
35
+ ```
36
+
37
+ 또는 직접 실행:
38
+
39
+ ```bash
40
+ uv run python -m thumbnail_maker
41
+ ```
42
+
43
+ ### 1. GUI 사용 (추천)
44
+
45
+ ```bash
46
+ uv run thumbnail-gui
47
+ ```
48
+
49
+ PySide6 기반 GUI에서 썸네일을 생성할 수 있습니다.
50
+
51
+ ### 2. CLI 사용
52
+
53
+ #### 기본 사용
54
+ ```bash
55
+ uv run generate-thumbnail
56
+ ```
57
+
58
+ #### DSL 파일 지정
59
+ ```bash
60
+ uv run generate-thumbnail mydsl.json -o output.png
61
+ ```
62
+
63
+ #### 간편 CLI (genthumb)
64
+ ```bash
65
+ # 기본
66
+ uv run genthumb
67
+
68
+ # 제목/부제목 덮어쓰기
69
+ uv run genthumb --title "새 제목" --subtitle "새 부제목"
70
+
71
+ # 배경 이미지 설정
72
+ uv run genthumb --bgImg bg.png
73
+
74
+ # 출력 파일 지정
75
+ uv run genthumb -o result.png
76
+ ```
77
+
78
+ ## 파일 구조
79
+
80
+ ```
81
+ thumbnail_maker/
82
+ ├── requirements.txt # Python 패키지 의존성
83
+ ├── thumbnailRenderer.py # 핵심 렌더링 로직
84
+ ├── generateThumbnail.py # 메인 생성 스크립트
85
+ ├── genthumb.py # 간편 CLI 스크립트
86
+ ├── main_gui.py # PySide6 GUI 애플리케이션
87
+ └── thumbnail.json # DSL 예제 파일
88
+ ```
89
+
90
+ ## DSL 파일 형식
91
+
92
+ ```json
93
+ {
94
+ "Thumbnail": {
95
+ "Resolution": {
96
+ "type": "preset",
97
+ "value": "16:9"
98
+ },
99
+ "Background": {
100
+ "type": "solid",
101
+ "color": "#a3e635"
102
+ },
103
+ "Texts": [
104
+ {
105
+ "type": "title",
106
+ "content": "제목 텍스트",
107
+ "gridPosition": "tl",
108
+ "font": {
109
+ "name": "SBAggroB",
110
+ "faces": [...]
111
+ },
112
+ "fontSize": 48,
113
+ "color": "#4ade80",
114
+ "outline": {
115
+ "thickness": 7,
116
+ "color": "#000000"
117
+ },
118
+ "enabled": true
119
+ }
120
+ ]
121
+ }
122
+ }
123
+ ```
124
+
125
+ ## 해상도 설정
126
+
127
+ ### Preset 모드
128
+ ```json
129
+ {
130
+ "type": "preset",
131
+ "value": "16:9" // "16:9", "9:16", "4:3", "1:1"
132
+ }
133
+ ```
134
+
135
+ ### Fixed Ratio 모드
136
+ ```json
137
+ {
138
+ "type": "fixedRatio",
139
+ "ratioValue": "16:9",
140
+ "width": 480 // 또는 height 지정
141
+ }
142
+ ```
143
+
144
+ ### Custom 모드
145
+ ```json
146
+ {
147
+ "type": "custom",
148
+ "width": 480,
149
+ "height": 270
150
+ }
151
+ ```
152
+
153
+ ## 기타
154
+
155
+ - JavaScript 버전의 파일들은 유지됩니다.
156
+ - 기존 DSL 파일과 호환됩니다.
157
+ - 폰트는 `fonts/` 디렉토리에 저장됩니다.
158
+