forked from kamilstanuch/Autocrop-vertical
-
Notifications
You must be signed in to change notification settings - Fork 420
Expand file tree
/
Copy pathhooks.py
More file actions
241 lines (198 loc) · 8.29 KB
/
hooks.py
File metadata and controls
241 lines (198 loc) · 8.29 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
import os
import textwrap
import subprocess
import urllib.request
from PIL import Image, ImageDraw, ImageFont, ImageFilter
FONT_URL = "https://github.com/googlefonts/noto-fonts/raw/main/hinted/ttf/NotoSerif/NotoSerif-Bold.ttf"
FONT_DIR = "fonts"
FONT_PATH = os.path.join(FONT_DIR, "NotoSerif-Bold.ttf")
def download_font_if_needed():
"""Downloads a serif font for the hook text if not present."""
if not os.path.exists(FONT_DIR):
os.makedirs(FONT_DIR)
if not os.path.exists(FONT_PATH):
print(f"⬇️ Downloading font from {FONT_URL}...")
try:
# Add user agent to avoid 403s slightly
req = urllib.request.Request(
FONT_URL,
headers={'User-Agent': 'Mozilla/5.0'}
)
with urllib.request.urlopen(req) as response, open(FONT_PATH, 'wb') as out_file:
out_file.write(response.read())
print("✅ Font downloaded.")
except Exception as e:
print(f"❌ Failed to download font: {e}")
def create_hook_image(text, target_width, output_image_path="hook_overlay.png", font_scale=1.0):
"""
Generates a white box with black serif text using pixel-based wrapping.
target_width: The max width the box should occupy (e.g. 85% of video)
"""
download_font_if_needed()
# Configuration
padding_x = 30 # Balanced padding
padding_y = 25
line_spacing = 20 # Increased spacing
cornerradius = 20
shadow_offset = (5, 5)
shadow_blur = 10
# Font Size Calculation (approx 5% of width - tuned to match Noto Serif Bold metrics in browser)
base_font_size = int(target_width * 0.05)
font_size = int(base_font_size * font_scale)
try:
font = ImageFont.truetype(FONT_PATH, font_size)
except Exception as e:
print(f"⚠️ Warning: Could not load font {FONT_PATH}, using default. Error: {e}")
font = ImageFont.load_default()
# Wrap text logic (Pixel-based)
dummy_img = Image.new('RGBA', (1, 1))
draw = ImageDraw.Draw(dummy_img)
max_text_width = target_width - (2 * padding_x)
# Handle manual newlines first
paragraphs = text.split('\n')
lines = []
for p in paragraphs:
if not p.strip():
lines.append("")
continue
words = p.split()
current_line = []
for word in words:
# Test if adding word fits
test_line = ' '.join(current_line + [word])
bbox = draw.textbbox((0, 0), test_line, font=font)
w = bbox[2] - bbox[0]
if w <= max_text_width:
current_line.append(word)
else:
# Line full, push current_line and start new
if current_line:
lines.append(' '.join(current_line))
current_line = [word]
else:
# Single word too long? Force it.
lines.append(word)
current_line = []
if current_line:
lines.append(' '.join(current_line))
# Recalculate true width/height
max_line_width = 0
text_heights = []
for line in lines:
if not line:
text_heights.append(font_size) # Use font size for empty line height
continue
bbox = draw.textbbox((0, 0), line, font=font)
w = bbox[2] - bbox[0]
h = bbox[3] - bbox[1]
max_line_width = max(max_line_width, w)
text_heights.append(h)
# Box dimensions
# We want the box to fit the text exactly + padding
# Ensure min width for aesthetic reasons if text is short (at least 30% of target)
box_width = max(max_line_width + (2 * padding_x), int(target_width * 0.3))
# Total Text Height: sum(heights) + spacing * (n-1)
if not text_heights:
total_text_height = font_size
else:
total_text_height = sum(text_heights) + (len(text_heights) - 1) * line_spacing
box_height = total_text_height + (2 * padding_y)
# Create Final Image with Rounded Corners and Shadow
# 1. Canvas for Shadow (larger than box)
canvas_w = box_width + 40
canvas_h = box_height + 40
img = Image.new('RGBA', (canvas_w, canvas_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# 2. Draw Shadow
shadow_box = [
(20 + shadow_offset[0], 20 + shadow_offset[1]),
(20 + box_width + shadow_offset[0], 20 + box_height + shadow_offset[1])
]
draw.rounded_rectangle(shadow_box, radius=cornerradius, fill=(0, 0, 0, 100))
# 3. Blur Shadow
img = img.filter(ImageFilter.GaussianBlur(5))
# 4. Draw White Box (sharper, on top of blurred shadow)
draw_final = ImageDraw.Draw(img)
main_box = [
(20, 20),
(20 + box_width, 20 + box_height)
]
# Semi-transparent white (240/255 alpha ~ 94% opacity)
draw_final.rounded_rectangle(main_box, radius=cornerradius, fill=(255, 255, 255, 240))
# 5. Draw Text
current_y = 20 + padding_y - 2 # Minor visual adjustment
for i, line in enumerate(lines):
if not line:
current_y += font_size + line_spacing
continue
bbox = draw_final.textbbox((0, 0), line, font=font)
line_w = bbox[2] - bbox[0]
line_h = text_heights[i] if i < len(text_heights) else bbox[3] - bbox[1]
# Center X
x = 20 + (box_width - line_w) // 2
# Draw Black Text
draw_final.text((x, current_y), line, font=font, fill="black")
current_y += line_h + line_spacing
img.save(output_image_path)
return output_image_path, canvas_w, canvas_h
def add_hook_to_video(video_path, text, output_path, position="top", font_scale=1.0):
"""
Overlays text hook onto video.
position: 'top', 'center', 'bottom'
font_scale: float multiplier (1.0 = default)
"""
if not os.path.exists(video_path):
raise FileNotFoundError(f"Video {video_path} not found")
# 1. Probe video width to scale text properly
try:
cmd = ['ffprobe', '-v', 'error', '-show_entries', 'stream=width,height', '-of', 'csv=s=x:p=0', video_path]
res = subprocess.check_output(cmd).decode().strip()
# Takes first stream if multiple
dims = res.split('\n')[0].split('x')
video_width = int(dims[0])
video_height = int(dims[1])
except Exception as e:
print(f"⚠️ FFprobe failed: {e}. Assuming 1080x1920")
video_width = 1080
video_height = 1920
# 2. Generate Image
# Box check: Don't let it be wider than 90% of screen
target_box_width = int(video_width * 0.9)
hook_filename = f"temp_hook_{os.path.basename(video_path)}.png"
# Ensure unique or temp location if needed, but relative is fine for this app structure
try:
img_path, box_w, box_h = create_hook_image(text, target_box_width, hook_filename, font_scale=font_scale)
# 3. Calculate Overlay Position
overlay_x = (video_width - box_w) // 2
if position == "center":
overlay_y = (video_height - box_h) // 2
elif position == "bottom":
# Bottom 20% mark (approx)
overlay_y = int(video_height * 0.70)
else:
# Top 20% mark
overlay_y = int(video_height * 0.20)
# 4. FFmpeg Command
print(f"🎬 Overlaying hook: '{text}' at {overlay_x},{overlay_y}")
ffmpeg_cmd = [
'ffmpeg', '-y',
'-i', video_path,
'-i', img_path,
'-filter_complex', f"[0:v][1:v]overlay={overlay_x}:{overlay_y}",
'-c:a', 'copy',
'-c:v', 'libx264', '-preset', 'fast', '-crf', '22',
output_path
]
subprocess.run(ffmpeg_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
print(f"✅ Hook added to {output_path}")
return True
except subprocess.CalledProcessError as e:
print(f"❌ FFmpeg Error: {e.stderr.decode() if e.stderr else 'Unknown'}")
raise e
except Exception as e:
print(f"❌ Hook Gen Error: {e}")
raise e
finally:
# Cleanup temp image
if os.path.exists(hook_filename):
os.remove(hook_filename)