thumbnail-maker 0.1.6__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.
- thumbnail_maker/__init__.py +9 -0
- thumbnail_maker/__main__.py +112 -0
- thumbnail_maker/cli.py +140 -0
- thumbnail_maker/gui/__init__.py +10 -0
- thumbnail_maker/gui/dsl_manager.py +351 -0
- thumbnail_maker/gui/font_utils.py +39 -0
- thumbnail_maker/gui/handlers.py +299 -0
- thumbnail_maker/gui/main_window.py +177 -0
- thumbnail_maker/gui/preview_thread.py +28 -0
- thumbnail_maker/gui/widgets.py +386 -0
- thumbnail_maker/renderer.py +597 -0
- thumbnail_maker/upload.py +279 -0
- thumbnail_maker-0.1.6.dist-info/METADATA +159 -0
- thumbnail_maker-0.1.6.dist-info/RECORD +16 -0
- thumbnail_maker-0.1.6.dist-info/WHEEL +4 -0
- thumbnail_maker-0.1.6.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
thumbnail_maker: 단일 엔트리포인트 (subcommands: gui, generate-thumbnail, genthumb, upload)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from .cli import main as generate_main, main_cli as genthumb_main
|
|
12
|
+
from .upload import upload_file
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> None:
|
|
16
|
+
parser = argparse.ArgumentParser(prog='thumbnail_maker', description='썸네일 메이커')
|
|
17
|
+
subparsers = parser.add_subparsers(dest='command', required=True)
|
|
18
|
+
|
|
19
|
+
# gui
|
|
20
|
+
subparsers.add_parser('gui', help='GUI 실행')
|
|
21
|
+
|
|
22
|
+
# generate-thumbnail (DSL만 사용)
|
|
23
|
+
gen = subparsers.add_parser('generate-thumbnail', help='DSL로 썸네일 생성')
|
|
24
|
+
gen.add_argument('dsl', nargs='?', default='thumbnail.json', help='DSL 파일 경로')
|
|
25
|
+
gen.add_argument('-o', '--output', default='thumbnail.png', help='출력 파일 경로')
|
|
26
|
+
gen.add_argument('--upload', action='store_true', help='생성 후 자동 업로드')
|
|
27
|
+
|
|
28
|
+
# genthumb (간편 CLI: 제목/부제목 덮어쓰기 등)
|
|
29
|
+
gt = subparsers.add_parser('genthumb', help='간편 CLI로 썸네일 생성')
|
|
30
|
+
gt.add_argument('dsl', nargs='?', default='thumbnail.json', help='DSL 파일 경로')
|
|
31
|
+
gt.add_argument('-o', '--output', default='thumbnail.png', help='출력 파일 경로')
|
|
32
|
+
gt.add_argument('--title', help='제목 덮어쓰기 (\\n 또는 실제 줄바꿈 지원)')
|
|
33
|
+
gt.add_argument('--subtitle', help='부제목 덮어쓰기 (\\n 또는 실제 줄바꿈 지원)')
|
|
34
|
+
gt.add_argument('--bgImg', help='배경 이미지 경로')
|
|
35
|
+
|
|
36
|
+
# upload
|
|
37
|
+
upload_parser = subparsers.add_parser('upload', help='이미지 파일 업로드')
|
|
38
|
+
upload_parser.add_argument('file', help='업로드할 파일 경로')
|
|
39
|
+
|
|
40
|
+
args, unknown = parser.parse_known_args()
|
|
41
|
+
|
|
42
|
+
if args.command == 'gui':
|
|
43
|
+
# GUI 명령어일 때만 PySide6 관련 모듈 import
|
|
44
|
+
from .gui import main as gui_main
|
|
45
|
+
gui_main()
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
if args.command == 'generate-thumbnail':
|
|
49
|
+
# generate_main은 자체 argparse를 사용하므로 여기서 직접 동작 위임이 어려움
|
|
50
|
+
# 동일 기능을 직접 수행하기보다는 해당 모듈의 메인 로직을 그대로 호출하도록 유지
|
|
51
|
+
# 간단하게는 모듈 내부가 파일 인자를 읽도록 짜여 있으므로, 여기서 args를 재적용
|
|
52
|
+
# 하지만 기존 main()은 sys.argv를 파싱하므로, 안전하게 별도 경로로 수행
|
|
53
|
+
# 대신 renderer를 직접 호출하지 않고, cli.main의 구현을 차용하기 위해 임시 argv 구성
|
|
54
|
+
sys.argv = ['generate-thumbnail', args.dsl, '-o', args.output]
|
|
55
|
+
generate_main()
|
|
56
|
+
|
|
57
|
+
# 업로드 옵션이 있으면 업로드 수행
|
|
58
|
+
if args.upload:
|
|
59
|
+
output_path = args.output
|
|
60
|
+
if not os.path.isabs(output_path):
|
|
61
|
+
output_path = os.path.abspath(output_path)
|
|
62
|
+
|
|
63
|
+
if not os.path.exists(output_path):
|
|
64
|
+
print(f"오류: 출력 파일을 찾을 수 없습니다: {output_path}")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
print(f"업로드 중: {output_path}")
|
|
68
|
+
url = upload_file(output_path)
|
|
69
|
+
if url:
|
|
70
|
+
print(f"✅ 업로드 완료: {url}")
|
|
71
|
+
else:
|
|
72
|
+
print("❌ 업로드 실패")
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
if args.command == 'genthumb':
|
|
77
|
+
# 동일 이유로 간편 CLI도 기존 파서를 활용하기 위해 argv 재구성
|
|
78
|
+
new_argv = ['genthumb']
|
|
79
|
+
if args.dsl:
|
|
80
|
+
new_argv.append(args.dsl)
|
|
81
|
+
if args.output:
|
|
82
|
+
new_argv += ['-o', args.output]
|
|
83
|
+
if args.title:
|
|
84
|
+
new_argv += ['--title', args.title]
|
|
85
|
+
if args.subtitle:
|
|
86
|
+
new_argv += ['--subtitle', args.subtitle]
|
|
87
|
+
if args.bgImg:
|
|
88
|
+
new_argv += ['--bgImg', args.bgImg]
|
|
89
|
+
sys.argv = new_argv
|
|
90
|
+
genthumb_main()
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
if args.command == 'upload':
|
|
94
|
+
file_path = args.file
|
|
95
|
+
if not os.path.exists(file_path):
|
|
96
|
+
print(f"오류: 파일을 찾을 수 없습니다: {file_path}")
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
print(f"업로드 중: {file_path}")
|
|
100
|
+
url = upload_file(file_path)
|
|
101
|
+
if url:
|
|
102
|
+
print(f"✅ 업로드 완료: {url}")
|
|
103
|
+
else:
|
|
104
|
+
print("❌ 업로드 실패")
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == '__main__':
|
|
110
|
+
main()
|
|
111
|
+
|
|
112
|
+
|
thumbnail_maker/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
CLI 스크립트
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
import json
|
|
10
|
+
import argparse
|
|
11
|
+
import base64
|
|
12
|
+
from .renderer import ThumbnailRenderer
|
|
13
|
+
import tempfile
|
|
14
|
+
import zipfile
|
|
15
|
+
import shutil
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
"""메인 CLI 진입점"""
|
|
20
|
+
parser = argparse.ArgumentParser(description='썸네일 생성')
|
|
21
|
+
parser.add_argument('dsl', nargs='?', default='thumbnail.json', help='DSL 파일 경로')
|
|
22
|
+
parser.add_argument('-o', '--output', default='thumbnail.png', help='출력 파일 경로')
|
|
23
|
+
|
|
24
|
+
args = parser.parse_args()
|
|
25
|
+
staging = None
|
|
26
|
+
cwd_backup = os.getcwd()
|
|
27
|
+
# 출력 경로를 절대경로로 확보 (staging 디렉토리 변경 전)
|
|
28
|
+
output_path = args.output
|
|
29
|
+
if not os.path.isabs(output_path):
|
|
30
|
+
output_path = os.path.abspath(output_path)
|
|
31
|
+
try:
|
|
32
|
+
# .thl 패키지 지원: 임시 폴더에 풀어서 작업
|
|
33
|
+
if args.dsl.lower().endswith('.thl') and os.path.exists(args.dsl):
|
|
34
|
+
staging = tempfile.mkdtemp(prefix='thl_run_')
|
|
35
|
+
with zipfile.ZipFile(args.dsl, 'r') as zf:
|
|
36
|
+
zf.extractall(staging)
|
|
37
|
+
# 작업 디렉토리를 패키지 루트로 변경 (renderer의 'fonts/' 탐색을 위함)
|
|
38
|
+
os.chdir(staging)
|
|
39
|
+
dsl_path = os.path.join(staging, 'thumbnail.json')
|
|
40
|
+
else:
|
|
41
|
+
dsl_path = args.dsl
|
|
42
|
+
|
|
43
|
+
# DSL 파일 확인
|
|
44
|
+
if not os.path.exists(dsl_path):
|
|
45
|
+
print(f"오류: DSL 파일을 찾을 수 없습니다: {dsl_path}")
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
|
|
48
|
+
# DSL 읽기
|
|
49
|
+
with open(dsl_path, 'r', encoding='utf-8') as f:
|
|
50
|
+
dsl = json.load(f)
|
|
51
|
+
|
|
52
|
+
# 썸네일 생성
|
|
53
|
+
ThumbnailRenderer.render_thumbnail(dsl, output_path)
|
|
54
|
+
finally:
|
|
55
|
+
try:
|
|
56
|
+
os.chdir(cwd_backup)
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
if staging:
|
|
60
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main_cli():
|
|
64
|
+
"""간편 CLI 진입점"""
|
|
65
|
+
parser = argparse.ArgumentParser(description='썸네일 생성 (간편 CLI)')
|
|
66
|
+
parser.add_argument('dsl', nargs='?', default='thumbnail.json', help='DSL 파일 경로')
|
|
67
|
+
parser.add_argument('-o', '--output', default='thumbnail.png', help='출력 파일 경로')
|
|
68
|
+
parser.add_argument('--title', help='제목 덮어쓰기 (\\n 또는 실제 줄바꿈 지원)')
|
|
69
|
+
parser.add_argument('--subtitle', help='부제목 덮어쓰기 (\\n 또는 실제 줄바꿈 지원)')
|
|
70
|
+
parser.add_argument('--bgImg', help='배경 이미지 경로')
|
|
71
|
+
|
|
72
|
+
args = parser.parse_args()
|
|
73
|
+
|
|
74
|
+
def normalize_text(s: str) -> str:
|
|
75
|
+
"""CLI에서 전달된 텍스트의 줄바꿈 시퀀스를 실제 줄바꿈으로 변환"""
|
|
76
|
+
if s is None:
|
|
77
|
+
return s
|
|
78
|
+
# 리터럴 \n, \r\n, \r 처리
|
|
79
|
+
# 먼저 \r\n -> \n 으로 통일, 이후 리터럴 역슬래시-n 치환
|
|
80
|
+
s = s.replace('\r\n', '\n').replace('\r', '\n')
|
|
81
|
+
s = s.replace('\\n', '\n')
|
|
82
|
+
return s
|
|
83
|
+
|
|
84
|
+
staging = None
|
|
85
|
+
cwd_backup = os.getcwd()
|
|
86
|
+
# 출력 경로를 절대경로로 확보 (staging 디렉토리 변경 전)
|
|
87
|
+
output_path = args.output
|
|
88
|
+
if not os.path.isabs(output_path):
|
|
89
|
+
output_path = os.path.abspath(output_path)
|
|
90
|
+
try:
|
|
91
|
+
# .thl 패키지 지원
|
|
92
|
+
if args.dsl and args.dsl.lower().endswith('.thl') and os.path.exists(args.dsl):
|
|
93
|
+
staging = tempfile.mkdtemp(prefix='thl_run_')
|
|
94
|
+
with zipfile.ZipFile(args.dsl, 'r') as zf:
|
|
95
|
+
zf.extractall(staging)
|
|
96
|
+
os.chdir(staging)
|
|
97
|
+
dsl_path = os.path.join(staging, 'thumbnail.json')
|
|
98
|
+
else:
|
|
99
|
+
dsl_path = args.dsl
|
|
100
|
+
|
|
101
|
+
# DSL 파일 확인
|
|
102
|
+
if not os.path.exists(dsl_path):
|
|
103
|
+
print(f"오류: DSL 파일을 찾을 수 없습니다: {dsl_path}")
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
# DSL 읽기
|
|
107
|
+
with open(dsl_path, 'r', encoding='utf-8') as f:
|
|
108
|
+
dsl = json.load(f)
|
|
109
|
+
|
|
110
|
+
# 배경 이미지 처리
|
|
111
|
+
if args.bgImg and os.path.exists(args.bgImg):
|
|
112
|
+
with open(args.bgImg, 'rb') as f:
|
|
113
|
+
image_data = f.read()
|
|
114
|
+
base64_str = base64.b64encode(image_data).decode('utf-8')
|
|
115
|
+
data_url = f"data:image/png;base64,{base64_str}"
|
|
116
|
+
|
|
117
|
+
dsl['Thumbnail']['Background']['type'] = 'image'
|
|
118
|
+
dsl['Thumbnail']['Background']['imagePath'] = data_url
|
|
119
|
+
|
|
120
|
+
# 제목/부제목 덮어쓰기
|
|
121
|
+
if 'Texts' in dsl.get('Thumbnail', {}):
|
|
122
|
+
for txt in dsl['Thumbnail']['Texts']:
|
|
123
|
+
if args.title and txt.get('type') == 'title':
|
|
124
|
+
txt['content'] = normalize_text(args.title)
|
|
125
|
+
if args.subtitle and txt.get('type') == 'subtitle':
|
|
126
|
+
txt['content'] = normalize_text(args.subtitle)
|
|
127
|
+
|
|
128
|
+
# 썸네일 생성
|
|
129
|
+
ThumbnailRenderer.render_thumbnail(dsl, output_path)
|
|
130
|
+
finally:
|
|
131
|
+
try:
|
|
132
|
+
os.chdir(cwd_backup)
|
|
133
|
+
except Exception:
|
|
134
|
+
pass
|
|
135
|
+
if staging:
|
|
136
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == '__main__':
|
|
140
|
+
main()
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
DSL 생성, 로드, 저장 관리 모듈
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import base64
|
|
10
|
+
import tempfile
|
|
11
|
+
import zipfile
|
|
12
|
+
import shutil
|
|
13
|
+
from typing import Dict
|
|
14
|
+
|
|
15
|
+
from ..renderer import ThumbnailRenderer, sanitize
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DSLManager:
|
|
19
|
+
"""DSL 관리 클래스"""
|
|
20
|
+
|
|
21
|
+
@staticmethod
|
|
22
|
+
def generate_dsl(gui) -> Dict:
|
|
23
|
+
"""GUI 위젯에서 DSL 생성"""
|
|
24
|
+
# 해상도 결정
|
|
25
|
+
res_mode = gui.res_mode.currentText()
|
|
26
|
+
if res_mode == 'preset':
|
|
27
|
+
resolution = {
|
|
28
|
+
'type': 'preset',
|
|
29
|
+
'value': gui.aspect_ratio.currentText()
|
|
30
|
+
}
|
|
31
|
+
elif res_mode == 'fixedRatio':
|
|
32
|
+
resolution = {
|
|
33
|
+
'type': 'fixedRatio',
|
|
34
|
+
'ratioValue': gui.aspect_ratio.currentText(),
|
|
35
|
+
'width': gui.width_spin.value()
|
|
36
|
+
}
|
|
37
|
+
else: # custom
|
|
38
|
+
resolution = {
|
|
39
|
+
'type': 'custom',
|
|
40
|
+
'width': gui.width_spin.value(),
|
|
41
|
+
'height': gui.height_spin.value()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# 배경 결정
|
|
45
|
+
bg_type = gui.bg_type.currentText()
|
|
46
|
+
if bg_type == 'image' and gui.bg_image_path.text():
|
|
47
|
+
# 이미지를 base64로 변환
|
|
48
|
+
with open(gui.bg_image_path.text(), 'rb') as f:
|
|
49
|
+
image_data = f.read()
|
|
50
|
+
base64_str = base64.b64encode(image_data).decode('utf-8')
|
|
51
|
+
data_url = f"data:image/png;base64,{base64_str}"
|
|
52
|
+
|
|
53
|
+
background = {
|
|
54
|
+
'type': 'image',
|
|
55
|
+
'imagePath': data_url,
|
|
56
|
+
'imageOpacity': gui.bg_opacity.value() / 100.0,
|
|
57
|
+
'imageBlur': gui.bg_blur.value()
|
|
58
|
+
}
|
|
59
|
+
elif bg_type == 'gradient':
|
|
60
|
+
background = {
|
|
61
|
+
'type': 'gradient',
|
|
62
|
+
'colors': [gui.bg_color, '#000000']
|
|
63
|
+
}
|
|
64
|
+
else: # solid
|
|
65
|
+
background = {
|
|
66
|
+
'type': 'solid',
|
|
67
|
+
'color': gui.bg_color
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# 텍스트 설정
|
|
71
|
+
# 제목 폰트 소스 분기
|
|
72
|
+
title_use_local = gui.title_font_source.currentText() == '로컬 폰트 파일'
|
|
73
|
+
title_face_url = gui.title_font_file.text() if title_use_local and gui.title_font_file.text() else (gui.title_font_url.text() or 'https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2108@1.1/SBAggroB.woff')
|
|
74
|
+
# 부제목 폰트 소스 분기
|
|
75
|
+
subtitle_use_local = gui.subtitle_font_source.currentText() == '로컬 폰트 파일'
|
|
76
|
+
subtitle_face_url = gui.subtitle_font_file.text() if subtitle_use_local and gui.subtitle_font_file.text() else (gui.subtitle_font_url.text() or 'https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2108@1.1/SBAggroB.woff')
|
|
77
|
+
|
|
78
|
+
texts = [
|
|
79
|
+
{
|
|
80
|
+
'type': 'title',
|
|
81
|
+
'content': gui.title_text.toPlainText(),
|
|
82
|
+
'gridPosition': gui.title_position.currentText(),
|
|
83
|
+
'font': {
|
|
84
|
+
'name': gui.title_font_name.text() or 'SBAggroB',
|
|
85
|
+
'faces': [{
|
|
86
|
+
'name': gui.title_font_name.text() or 'SBAggroB',
|
|
87
|
+
'url': title_face_url,
|
|
88
|
+
'weight': gui.title_font_weight.currentText() or 'bold',
|
|
89
|
+
'style': gui.title_font_style.currentText() or 'normal'
|
|
90
|
+
}]
|
|
91
|
+
},
|
|
92
|
+
'fontSize': gui.title_font_size.value(),
|
|
93
|
+
'color': gui.title_color,
|
|
94
|
+
'fontWeight': gui.title_font_weight.currentText() or 'bold',
|
|
95
|
+
'fontStyle': gui.title_font_style.currentText() or 'normal',
|
|
96
|
+
'lineHeight': 1.1,
|
|
97
|
+
'wordWrap': gui.title_word_wrap.isChecked(),
|
|
98
|
+
'outline': {
|
|
99
|
+
'thickness': gui.title_outline_thickness.value(),
|
|
100
|
+
'color': '#000000'
|
|
101
|
+
} if gui.title_outline_check.isChecked() else None,
|
|
102
|
+
'enabled': True
|
|
103
|
+
}
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
# 부제목 추가 (체크 여부와 관계없이 항상 추가하되, enabled 필드로 제어)
|
|
107
|
+
texts.append({
|
|
108
|
+
'type': 'subtitle',
|
|
109
|
+
'content': gui.subtitle_text.toPlainText(),
|
|
110
|
+
'gridPosition': gui.subtitle_position.currentText(),
|
|
111
|
+
'font': {
|
|
112
|
+
'name': gui.subtitle_font_name.text() or 'SBAggroB',
|
|
113
|
+
'faces': [{
|
|
114
|
+
'name': gui.subtitle_font_name.text() or 'SBAggroB',
|
|
115
|
+
'url': subtitle_face_url,
|
|
116
|
+
'weight': gui.subtitle_font_weight.currentText() or 'normal',
|
|
117
|
+
'style': gui.subtitle_font_style.currentText() or 'normal'
|
|
118
|
+
}]
|
|
119
|
+
},
|
|
120
|
+
'fontSize': gui.subtitle_font_size.value(),
|
|
121
|
+
'color': gui.subtitle_color,
|
|
122
|
+
'fontWeight': gui.subtitle_font_weight.currentText() or 'normal',
|
|
123
|
+
'fontStyle': gui.subtitle_font_style.currentText() or 'normal',
|
|
124
|
+
'lineHeight': 1.1,
|
|
125
|
+
'wordWrap': gui.subtitle_word_wrap.isChecked(),
|
|
126
|
+
'outline': None,
|
|
127
|
+
'enabled': gui.subtitle_visible.isChecked()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
dsl = {
|
|
131
|
+
'Thumbnail': {
|
|
132
|
+
'Resolution': resolution,
|
|
133
|
+
'Background': background,
|
|
134
|
+
'Texts': texts
|
|
135
|
+
},
|
|
136
|
+
'TemplateMeta': {
|
|
137
|
+
'name': '',
|
|
138
|
+
'shareable': False
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return dsl
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def load_dsl_to_gui(gui, dsl: Dict):
|
|
146
|
+
"""DSL 데이터를 GUI 위젯에 로드"""
|
|
147
|
+
thumbnail = dsl.get('Thumbnail', {})
|
|
148
|
+
|
|
149
|
+
# 해상도 설정
|
|
150
|
+
resolution = thumbnail.get('Resolution', {})
|
|
151
|
+
res_type = resolution.get('type', 'preset')
|
|
152
|
+
gui.res_mode.setCurrentText(res_type)
|
|
153
|
+
|
|
154
|
+
if res_type == 'preset':
|
|
155
|
+
gui.aspect_ratio.setCurrentText(resolution.get('value', '16:9'))
|
|
156
|
+
elif res_type == 'fixedRatio':
|
|
157
|
+
gui.aspect_ratio.setCurrentText(resolution.get('ratioValue', '16:9'))
|
|
158
|
+
gui.width_spin.setValue(resolution.get('width', 480))
|
|
159
|
+
else: # custom
|
|
160
|
+
gui.width_spin.setValue(resolution.get('width', 480))
|
|
161
|
+
gui.height_spin.setValue(resolution.get('height', 270))
|
|
162
|
+
|
|
163
|
+
# 배경 설정
|
|
164
|
+
background = thumbnail.get('Background', {})
|
|
165
|
+
bg_type = background.get('type', 'solid')
|
|
166
|
+
gui.bg_type.setCurrentText(bg_type)
|
|
167
|
+
|
|
168
|
+
if bg_type == 'solid':
|
|
169
|
+
gui.bg_color = background.get('color', '#a3e635')
|
|
170
|
+
elif bg_type == 'gradient':
|
|
171
|
+
colors = background.get('colors', [])
|
|
172
|
+
if colors:
|
|
173
|
+
gui.bg_color = colors[0]
|
|
174
|
+
elif bg_type == 'image':
|
|
175
|
+
img_path = background.get('imagePath', '')
|
|
176
|
+
if img_path.startswith('data:image'):
|
|
177
|
+
# base64 이미지는 임시 파일로 저장
|
|
178
|
+
header, encoded = img_path.split(',', 1)
|
|
179
|
+
image_data = base64.b64decode(encoded)
|
|
180
|
+
temp_path = os.path.join(tempfile.gettempdir(), 'thl_temp_bg.png')
|
|
181
|
+
with open(temp_path, 'wb') as f:
|
|
182
|
+
f.write(image_data)
|
|
183
|
+
gui.bg_image_path.setText(temp_path)
|
|
184
|
+
else:
|
|
185
|
+
gui.bg_image_path.setText(img_path)
|
|
186
|
+
gui.bg_opacity.setValue(int(background.get('imageOpacity', 1.0) * 100))
|
|
187
|
+
gui.bg_blur.setValue(background.get('imageBlur', 0))
|
|
188
|
+
|
|
189
|
+
# 텍스트 설정
|
|
190
|
+
texts = thumbnail.get('Texts', [])
|
|
191
|
+
subtitle_found = False
|
|
192
|
+
for txt in texts:
|
|
193
|
+
txt_type = txt.get('type')
|
|
194
|
+
if txt_type == 'title':
|
|
195
|
+
gui.title_text.setPlainText(txt.get('content', ''))
|
|
196
|
+
gui.title_position.setCurrentText(txt.get('gridPosition', 'tl'))
|
|
197
|
+
gui.title_font_size.setValue(txt.get('fontSize', 48))
|
|
198
|
+
gui.title_color = txt.get('color', '#4ade80')
|
|
199
|
+
|
|
200
|
+
font = txt.get('font', {})
|
|
201
|
+
font_name = font.get('name', 'SBAggroB')
|
|
202
|
+
gui.title_font_name.setText(font_name)
|
|
203
|
+
|
|
204
|
+
faces = font.get('faces', [])
|
|
205
|
+
if faces:
|
|
206
|
+
face = faces[0]
|
|
207
|
+
url = face.get('url', '')
|
|
208
|
+
if url and os.path.exists(url):
|
|
209
|
+
# 로컬 파일
|
|
210
|
+
gui.title_font_source.setCurrentText('로컬 폰트 파일')
|
|
211
|
+
gui.title_font_file.setText(url)
|
|
212
|
+
else:
|
|
213
|
+
# 웹 URL
|
|
214
|
+
gui.title_font_source.setCurrentText('웹 폰트 URL')
|
|
215
|
+
gui.title_font_url.setText(url)
|
|
216
|
+
|
|
217
|
+
gui.title_font_weight.setCurrentText(face.get('weight', 'bold'))
|
|
218
|
+
gui.title_font_style.setCurrentText(face.get('style', 'normal'))
|
|
219
|
+
|
|
220
|
+
outline = txt.get('outline')
|
|
221
|
+
if outline:
|
|
222
|
+
gui.title_outline_check.setChecked(True)
|
|
223
|
+
gui.title_outline_thickness.setValue(outline.get('thickness', 7))
|
|
224
|
+
else:
|
|
225
|
+
gui.title_outline_check.setChecked(False)
|
|
226
|
+
|
|
227
|
+
gui.title_word_wrap.setChecked(txt.get('wordWrap', False))
|
|
228
|
+
|
|
229
|
+
elif txt_type == 'subtitle':
|
|
230
|
+
subtitle_found = True
|
|
231
|
+
# enabled 필드를 확인하여 체크박스 상태 설정
|
|
232
|
+
enabled = txt.get('enabled', True)
|
|
233
|
+
gui.subtitle_visible.setChecked(enabled)
|
|
234
|
+
gui.subtitle_text.setPlainText(txt.get('content', ''))
|
|
235
|
+
gui.subtitle_position.setCurrentText(txt.get('gridPosition', 'bl'))
|
|
236
|
+
gui.subtitle_font_size.setValue(txt.get('fontSize', 24))
|
|
237
|
+
gui.subtitle_color = txt.get('color', '#ffffff')
|
|
238
|
+
|
|
239
|
+
font = txt.get('font', {})
|
|
240
|
+
font_name = font.get('name', 'SBAggroB')
|
|
241
|
+
gui.subtitle_font_name.setText(font_name)
|
|
242
|
+
|
|
243
|
+
faces = font.get('faces', [])
|
|
244
|
+
if faces:
|
|
245
|
+
face = faces[0]
|
|
246
|
+
url = face.get('url', '')
|
|
247
|
+
if url and os.path.exists(url):
|
|
248
|
+
# 로컬 파일
|
|
249
|
+
gui.subtitle_font_source.setCurrentText('로컬 폰트 파일')
|
|
250
|
+
gui.subtitle_font_file.setText(url)
|
|
251
|
+
else:
|
|
252
|
+
# 웹 URL
|
|
253
|
+
gui.subtitle_font_source.setCurrentText('웹 폰트 URL')
|
|
254
|
+
gui.subtitle_font_url.setText(url)
|
|
255
|
+
|
|
256
|
+
gui.subtitle_font_weight.setCurrentText(face.get('weight', 'normal'))
|
|
257
|
+
gui.subtitle_font_style.setCurrentText(face.get('style', 'normal'))
|
|
258
|
+
|
|
259
|
+
gui.subtitle_word_wrap.setChecked(txt.get('wordWrap', False))
|
|
260
|
+
|
|
261
|
+
# 부제목이 DSL에 없으면 체크 해제
|
|
262
|
+
if not subtitle_found:
|
|
263
|
+
gui.subtitle_visible.setChecked(False)
|
|
264
|
+
|
|
265
|
+
# 미리보기 업데이트
|
|
266
|
+
gui.update_preview()
|
|
267
|
+
|
|
268
|
+
@staticmethod
|
|
269
|
+
def save_thl_package(gui, file_path: str):
|
|
270
|
+
"""현재 DSL과 사용 폰트를 묶어 .thl 패키지로 저장"""
|
|
271
|
+
if not hasattr(gui, 'current_dsl'):
|
|
272
|
+
gui.update_preview()
|
|
273
|
+
dsl = getattr(gui, 'current_dsl', DSLManager.generate_dsl(gui))
|
|
274
|
+
|
|
275
|
+
staging = None
|
|
276
|
+
try:
|
|
277
|
+
# 스테이징 디렉토리 구성
|
|
278
|
+
staging = tempfile.mkdtemp(prefix='thl_pkg_')
|
|
279
|
+
fonts_dir = os.path.join(staging, 'fonts')
|
|
280
|
+
os.makedirs(fonts_dir, exist_ok=True)
|
|
281
|
+
|
|
282
|
+
# 폰트 확보 및 복사
|
|
283
|
+
texts = dsl.get('Thumbnail', {}).get('Texts', [])
|
|
284
|
+
try:
|
|
285
|
+
# 프로젝트/fonts에 TTF 생성/보장
|
|
286
|
+
ThumbnailRenderer.ensure_fonts(texts)
|
|
287
|
+
except Exception as e:
|
|
288
|
+
print(f"폰트 확보 경고: {e}")
|
|
289
|
+
|
|
290
|
+
# faces를 순회하여 예상 파일명으로 복사
|
|
291
|
+
faces = ThumbnailRenderer.parse_font_faces(texts)
|
|
292
|
+
for face in faces:
|
|
293
|
+
ttf_name = f"{sanitize(face.get('name','Font'))}-{sanitize(str(face.get('weight','normal')))}-{sanitize(str(face.get('style','normal')))}.ttf"
|
|
294
|
+
src_path = os.path.join(ThumbnailRenderer._fonts_dir(), ttf_name)
|
|
295
|
+
if os.path.exists(src_path):
|
|
296
|
+
shutil.copy2(src_path, os.path.join(fonts_dir, ttf_name))
|
|
297
|
+
|
|
298
|
+
# thumbnail.json 저장 (원본 DSL 그대로)
|
|
299
|
+
with open(os.path.join(staging, 'thumbnail.json'), 'w', encoding='utf-8') as f:
|
|
300
|
+
json.dump(dsl, f, ensure_ascii=False, indent=2)
|
|
301
|
+
|
|
302
|
+
# zip -> .thl
|
|
303
|
+
with zipfile.ZipFile(file_path, 'w', compression=zipfile.ZIP_DEFLATED) as zf:
|
|
304
|
+
# 루트에 thumbnail.json
|
|
305
|
+
zf.write(os.path.join(staging, 'thumbnail.json'), arcname='thumbnail.json')
|
|
306
|
+
# fonts 폴더
|
|
307
|
+
if os.path.isdir(fonts_dir):
|
|
308
|
+
for name in os.listdir(fonts_dir):
|
|
309
|
+
zf.write(os.path.join(fonts_dir, name), arcname=os.path.join('fonts', name))
|
|
310
|
+
finally:
|
|
311
|
+
if staging:
|
|
312
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
313
|
+
|
|
314
|
+
@staticmethod
|
|
315
|
+
def load_thl_package(gui, file_path: str) -> Dict:
|
|
316
|
+
""".thl 패키지를 로드하여 DSL 반환"""
|
|
317
|
+
staging = None
|
|
318
|
+
cwd_backup = os.getcwd()
|
|
319
|
+
try:
|
|
320
|
+
# .thl 파일을 임시 디렉토리에 압축 해제
|
|
321
|
+
staging = tempfile.mkdtemp(prefix='thl_load_')
|
|
322
|
+
with zipfile.ZipFile(file_path, 'r') as zf:
|
|
323
|
+
zf.extractall(staging)
|
|
324
|
+
|
|
325
|
+
# thumbnail.json 읽기
|
|
326
|
+
dsl_path = os.path.join(staging, 'thumbnail.json')
|
|
327
|
+
if not os.path.exists(dsl_path):
|
|
328
|
+
raise FileNotFoundError('패키지에 thumbnail.json이 없습니다.')
|
|
329
|
+
|
|
330
|
+
with open(dsl_path, 'r', encoding='utf-8') as f:
|
|
331
|
+
dsl = json.load(f)
|
|
332
|
+
|
|
333
|
+
# 폰트 파일을 프로젝트 fonts 디렉토리로 복사
|
|
334
|
+
fonts_src_dir = os.path.join(staging, 'fonts')
|
|
335
|
+
if os.path.isdir(fonts_src_dir):
|
|
336
|
+
fonts_dst_dir = ThumbnailRenderer._fonts_dir()
|
|
337
|
+
os.makedirs(fonts_dst_dir, exist_ok=True)
|
|
338
|
+
for font_file in os.listdir(fonts_src_dir):
|
|
339
|
+
src_path = os.path.join(fonts_src_dir, font_file)
|
|
340
|
+
dst_path = os.path.join(fonts_dst_dir, font_file)
|
|
341
|
+
shutil.copy2(src_path, dst_path)
|
|
342
|
+
|
|
343
|
+
return dsl
|
|
344
|
+
finally:
|
|
345
|
+
try:
|
|
346
|
+
os.chdir(cwd_backup)
|
|
347
|
+
except Exception:
|
|
348
|
+
pass
|
|
349
|
+
if staging:
|
|
350
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
351
|
+
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
폰트 관련 유틸리티 함수
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from fontTools.ttLib import TTFont
|
|
11
|
+
except Exception:
|
|
12
|
+
TTFont = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def infer_font_name_from_file(file_path: str) -> str:
|
|
16
|
+
"""파일 경로에서 폰트 이름 추출"""
|
|
17
|
+
try:
|
|
18
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
19
|
+
if TTFont and ext in ('.ttf', '.otf') and os.path.exists(file_path):
|
|
20
|
+
tt = TTFont(file_path)
|
|
21
|
+
# Prefer full font name (nameID=4), fallback to font family (nameID=1)
|
|
22
|
+
name = None
|
|
23
|
+
for rec in tt['name'].names:
|
|
24
|
+
if rec.nameID in (4, 1):
|
|
25
|
+
try:
|
|
26
|
+
val = rec.toUnicode()
|
|
27
|
+
except Exception:
|
|
28
|
+
val = rec.string.decode(rec.getEncoding(), errors='ignore')
|
|
29
|
+
if val:
|
|
30
|
+
name = val
|
|
31
|
+
if rec.nameID == 4:
|
|
32
|
+
break
|
|
33
|
+
if name:
|
|
34
|
+
return name
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
37
|
+
# Fallback: 파일명(확장자 제외)
|
|
38
|
+
return os.path.splitext(os.path.basename(file_path))[0]
|
|
39
|
+
|