feat: implement vertical crop, local LLM deepseek-v4-flash, multi-threading, word-level pink box highlight subtitles, and Nextcloud scan sync
This commit is contained in:
@@ -0,0 +1,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)
|
||||
Reference in New Issue
Block a user