Files
karuo-ai/03_卡木(木)/木叶_视频内容/视频切片/脚本/visual_enhance.py

692 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
视觉增强 v6非固定结构的立体苹果毛玻璃舞台
设计原则:
1. 原视频完全不变,仅底部叠加动态舞台。
2. 每条视频自动派生独立风格:配色、窗口位置、芯片布局、信息结构都不同。
3. 强化黑色高级感、阴影、悬浮、玻璃高光与动态小视频窗。
4. 所有信息模块与文字均为动态生成,不使用固定平面模板。
"""
import argparse
import hashlib
import json
import math
import os
import subprocess
import sys
import tempfile
from pathlib import Path
try:
from PIL import Image, ImageDraw, ImageFilter, ImageFont
except ImportError:
sys.exit('需要 Pillow: pip3 install Pillow')
SCRIPT_DIR = Path(__file__).resolve().parent
FONTS_DIR = SCRIPT_DIR.parent / 'fonts'
if not FONTS_DIR.exists():
FONTS_DIR = Path('/Users/karuo/Documents/个人/卡若AI/03_卡木/木叶_视频内容/视频切片/fonts')
VW, VH = 498, 1080
PANEL_W, PANEL_H = 422, 340
PANEL_X = (VW - PANEL_W) // 2
PANEL_Y = VH - PANEL_H - 26
FPS = 8
CURRENT_VIDEO_SEED = 'default'
WHITE = (248, 250, 255, 255)
BLUE = (96, 165, 250, 255)
PURPLE = (167, 139, 250, 255)
GREEN = (52, 211, 153, 255)
GOLD = (251, 191, 36, 255)
ORANGE = (251, 146, 60, 255)
RED = (248, 113, 113, 255)
CYAN = (34, 211, 238, 255)
PALETTES = [
{
'name': 'obsidian-cyan',
'glass_top': (18, 25, 40, 212),
'glass_bottom': (10, 14, 28, 226),
'back_a': (35, 58, 120, 92),
'back_b': (24, 112, 168, 70),
'title': (246, 249, 255, 255),
'text': (223, 231, 244, 255),
'sub': (160, 178, 205, 255),
'muted': (103, 119, 148, 255),
'accents': [(98, 182, 255, 255), (46, 211, 238, 255), (139, 92, 246, 255), (250, 204, 21, 255)]
},
{
'name': 'midnight-violet',
'glass_top': (28, 22, 46, 214),
'glass_bottom': (14, 10, 26, 228),
'back_a': (88, 54, 156, 90),
'back_b': (44, 63, 113, 76),
'title': (248, 247, 255, 255),
'text': (233, 229, 248, 255),
'sub': (184, 169, 214, 255),
'muted': (118, 107, 144, 255),
'accents': [(167, 139, 250, 255), (96, 165, 250, 255), (244, 114, 182, 255), (251, 191, 36, 255)]
},
{
'name': 'graphite-gold',
'glass_top': (24, 24, 30, 212),
'glass_bottom': (10, 11, 16, 228),
'back_a': (92, 71, 29, 86),
'back_b': (46, 70, 115, 70),
'title': (250, 249, 245, 255),
'text': (235, 233, 225, 255),
'sub': (187, 180, 160, 255),
'muted': (123, 118, 102, 255),
'accents': [(251, 191, 36, 255), (245, 158, 11, 255), (96, 165, 250, 255), (52, 211, 153, 255)]
},
{
'name': 'ink-emerald',
'glass_top': (16, 30, 28, 212),
'glass_bottom': (9, 16, 18, 226),
'back_a': (26, 95, 78, 90),
'back_b': (34, 84, 131, 72),
'title': (244, 252, 249, 255),
'text': (218, 243, 236, 255),
'sub': (151, 194, 183, 255),
'muted': (103, 144, 136, 255),
'accents': [(52, 211, 153, 255), (45, 212, 191, 255), (96, 165, 250, 255), (251, 191, 36, 255)]
},
]
def font(size: int, weight='medium'):
if isinstance(weight, bool):
weight = 'bold' if weight else 'medium'
mapping = {
'regular': [
FONTS_DIR / 'NotoSansCJK-Regular.ttc',
FONTS_DIR / 'SourceHanSansSC-Medium.otf',
Path('/System/Library/Fonts/PingFang.ttc'),
],
'medium': [
FONTS_DIR / 'SourceHanSansSC-Medium.otf',
FONTS_DIR / 'NotoSansCJK-Regular.ttc',
Path('/System/Library/Fonts/PingFang.ttc'),
],
'semibold': [
FONTS_DIR / 'SourceHanSansSC-Bold.otf',
FONTS_DIR / 'NotoSansCJK-Bold.ttc',
Path('/System/Library/Fonts/PingFang.ttc'),
],
'bold': [
FONTS_DIR / 'SourceHanSansSC-Heavy.otf',
FONTS_DIR / 'SourceHanSansSC-Bold.otf',
FONTS_DIR / 'NotoSansCJK-Bold.ttc',
Path('/System/Library/Fonts/PingFang.ttc'),
],
}
for path in mapping.get(weight, mapping['medium']):
if path.exists():
try:
return ImageFont.truetype(str(path), size)
except Exception:
continue
return ImageFont.load_default()
def ease_out(t: float) -> float:
t = max(0.0, min(1.0, t))
return 1 - (1 - t) ** 3
def blend(c1, c2, t):
return tuple(int(a + (b - a) * t) for a, b in zip(c1, c2))
def hash_int(text: str) -> int:
return int(hashlib.md5(text.encode('utf-8')).hexdigest()[:8], 16)
def build_profile(scene, idx: int):
key = CURRENT_VIDEO_SEED + '|' + scene.get('type', '') + '|' + str(idx)
seed = hash_int(key)
palette = PALETTES[seed % len(PALETTES)]
return {
'seed': seed,
'palette': palette,
'window_variant': seed % 4,
'chip_variant': (seed // 7) % 4,
'content_variant': (seed // 13) % 3,
'tag_variant': (seed // 17) % 3,
}
def rounded(draw, xy, radius, **kwargs):
draw.rounded_rectangle(xy, radius=radius, **kwargs)
def draw_center(draw, text, fnt, y, fill, width):
bbox = fnt.getbbox(text)
tw = bbox[2] - bbox[0]
draw.text(((width - tw) // 2, y), text, font=fnt, fill=fill)
def draw_wrap(draw, text, fnt, max_w, x, y, fill, line_gap=6):
lines, cur = [], ''
for ch in text:
candidate = cur + ch
bbox = fnt.getbbox(candidate)
if bbox[2] - bbox[0] > max_w and cur:
lines.append(cur)
cur = ch
else:
cur = candidate
if cur:
lines.append(cur)
for line in lines:
draw.text((x, y), line, font=fnt, fill=fill)
bbox = fnt.getbbox(line)
y += (bbox[3] - bbox[1]) + line_gap
return y
def draw_wrap_center(draw, text, fnt, max_w, y, fill, width, line_gap=6):
lines, cur = [], ''
for ch in text:
candidate = cur + ch
bbox = fnt.getbbox(candidate)
if bbox[2] - bbox[0] > max_w and cur:
lines.append(cur)
cur = ch
else:
cur = candidate
if cur:
lines.append(cur)
for line in lines:
bbox = fnt.getbbox(line)
tw = bbox[2] - bbox[0]
draw.text(((width - tw) // 2, y), line, font=fnt, fill=fill)
y += (bbox[3] - bbox[1]) + line_gap
return y
def create_shadow(size, blur_radius=24, alpha=138):
w, h = size
img = Image.new('RGBA', (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
d.rounded_rectangle((14, 20, w - 18, h - 4), radius=30, fill=(0, 0, 0, alpha))
d.rounded_rectangle((30, 36, w - 34, h - 16), radius=26, fill=(10, 20, 40, int(alpha * 0.25)))
return img.filter(ImageFilter.GaussianBlur(blur_radius))
def create_back_plate(profile, progress: float):
pal = profile['palette']
img = Image.new('RGBA', (PANEL_W, PANEL_H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
pulse = 0.78 + 0.22 * math.sin(progress * math.pi * 2.0)
c1 = tuple(int(v * pulse) for v in pal['back_a'][:3]) + (pal['back_a'][3],)
c2 = tuple(int(v * pulse) for v in pal['back_b'][:3]) + (pal['back_b'][3],)
rounded(d, (18, 12, PANEL_W - 8, PANEL_H - 8), 30, fill=c1)
rounded(d, (10, 26, PANEL_W - 24, PANEL_H - 20), 26, fill=c2)
return img.filter(ImageFilter.GaussianBlur(1.5))
def create_glass_panel(profile):
pal = profile['palette']
img = Image.new('RGBA', (PANEL_W, PANEL_H), (0, 0, 0, 0))
grad = Image.new('RGBA', (PANEL_W, PANEL_H), (0, 0, 0, 0))
gd = ImageDraw.Draw(grad)
for y in range(PANEL_H):
t = y / max(PANEL_H - 1, 1)
gd.line([(0, y), (PANEL_W, y)], fill=blend(pal['glass_top'], pal['glass_bottom'], t))
mask = Image.new('L', (PANEL_W, PANEL_H), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, PANEL_W - 1, PANEL_H - 1), radius=30, fill=255)
img = Image.composite(grad, img, mask)
d = ImageDraw.Draw(img)
rounded(d, (0, 0, PANEL_W - 1, PANEL_H - 1), 30, outline=(255, 255, 255, 72), width=1)
rounded(d, (10, 12, PANEL_W - 10, PANEL_H - 10), 24, outline=(255, 255, 255, 24), width=1)
for y in range(26):
alpha = int(32 * (1 - y / 26))
d.line([(24, y + 4), (PANEL_W - 24, y + 4)], fill=(255, 255, 255, alpha))
return img
def create_chip(text, accent, fg, active=1.0):
w = max(68, 22 + len(text) * 14)
h = 34
img = Image.new('RGBA', (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
rounded(d, (0, 0, w - 1, h - 1), 17, fill=(20, 24, 38, int(192 * active)), outline=accent[:3] + (int(150 * active),), width=1)
ff = font(13, 'medium')
bbox = ff.getbbox(text)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
d.text(((w - tw) // 2, (h - th) // 2 - 1), text, font=ff, fill=fg[:3] + (int(255 * active),))
return img
def create_metric_card(title, value, subtitle, accent, profile, progress):
pal = profile['palette']
w, h = 118, 84
img = Image.new('RGBA', (w, h), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
rounded(d, (0, 0, w - 1, h - 1), 18, fill=(18, 24, 38, 220), outline=accent[:3] + (125,), width=1)
d.rounded_rectangle((12, 14, 16, h - 14), radius=2, fill=accent[:3] + (230,))
d.text((24, 12), title, font=font(11, 'medium'), fill=pal['sub'])
d.text((24, 34), value, font=font(21, 'medium'), fill=accent)
d.text((24, 60), subtitle, font=font(10, 'regular'), fill=pal['muted'])
dot_r = 5 + int(2 * math.sin(progress * math.pi * 2))
d.ellipse((w - 20 - dot_r, 12 - dot_r // 2, w - 20 + dot_r, 12 + dot_r), fill=accent[:3] + (200,))
return img
def create_video_window(scene_type: str, t: float, profile):
w, h = 142, 100
pal = profile['palette']
accents = pal['accents']
img = Image.new('RGBA', (w, h), (0, 0, 0, 0))
base = Image.new('RGBA', (w, h), (0, 0, 0, 0))
bd = ImageDraw.Draw(base)
top_bg = tuple(max(0, c - 12) for c in pal['glass_top'][:3]) + (255,)
bottom_bg = tuple(max(0, c - 8) for c in pal['glass_bottom'][:3]) + (255,)
for y in range(h):
ratio = y / max(h - 1, 1)
bd.line([(0, y), (w, y)], fill=blend(top_bg, bottom_bg, ratio))
mask = Image.new('L', (w, h), 0)
ImageDraw.Draw(mask).rounded_rectangle((0, 0, w - 1, h - 1), radius=18, fill=255)
img = Image.composite(base, img, mask)
d = ImageDraw.Draw(img)
rounded(d, (0, 0, w - 1, h - 1), 18, outline=(255, 255, 255, 64), width=1)
scan_x = int((math.sin(t * 1.7) * 0.5 + 0.5) * (w + 44)) - 22
for i in range(-6, 7):
alpha = max(0, 34 - abs(i) * 5)
d.line([(scan_x + i, 6), (scan_x + i - 18, h - 6)], fill=(255, 255, 255, alpha))
variant = profile['window_variant']
if variant == 0:
for idx in range(14):
phase = t * 1.4 + idx * 0.6
px = int((math.sin(phase * 1.2) * 0.45 + 0.5) * (w - 20)) + 10
py = int((math.cos(phase * 0.9 + idx) * 0.35 + 0.5) * (h - 24)) + 12
r = 2 + (idx % 2)
color = accents[idx % len(accents)][:3] + (160,)
d.ellipse((px - r, py - r, px + r, py + r), fill=color)
elif variant == 1:
for i in range(5):
bh = 10 + int((math.sin(t * 2 + i) * 0.5 + 0.5) * 30)
x = 16 + i * 22
d.rounded_rectangle((x, h - 16 - bh, x + 12, h - 16), radius=4, fill=accents[i % len(accents)][:3] + (190,))
d.line((12, 68, 42, 54, 70, 60, 100, 32, 132, 24), fill=accents[1][:3] + (220,), width=3)
elif variant == 2:
pts = [(20, 22), (42, 40), (68, 32), (92, 58), (116, 46)]
visible = 2 + int((math.sin(t * 1.1) * 0.5 + 0.5) * 3)
for i in range(len(pts) - 1):
c = accents[i % len(accents)][:3] + (180 if i < visible else 60,)
d.line((pts[i], pts[i + 1]), fill=c, width=3)
for i, (px, py) in enumerate(pts):
r = 5 + int(1.4 * math.sin(t * 2 + i))
color = accents[(i + 1) % len(accents)][:3] + (220 if i < visible + 1 else 90,)
d.ellipse((px - r, py - r, px + r, py + r), fill=color)
else:
cx, cy = 72, 52
d.ellipse((cx - 10, cy - 10, cx + 10, cy + 10), fill=accents[0][:3] + (220,))
for i in range(6):
ang = math.radians(i * 60 + t * 18)
px = cx + math.cos(ang) * 32
py = cy + math.sin(ang) * 24
d.line((cx, cy, px, py), fill=accents[(i + 1) % len(accents)][:3] + (120,), width=2)
d.ellipse((px - 5, py - 5, px + 5, py + 5), fill=accents[i % len(accents)][:3] + (210,))
d.ellipse((12, 10, 18, 16), fill=(255, 95, 86, 220))
d.ellipse((22, 10, 28, 16), fill=(255, 189, 46, 220))
d.ellipse((32, 10, 38, 16), fill=(39, 201, 63, 220))
return img
def place_video_window(base, window_img, profile):
positions = [
(PANEL_W - 158, 18),
(22, 44),
(PANEL_W - 158, 124),
(140, 18),
]
base.alpha_composite(window_img, positions[profile['window_variant'] % len(positions)])
def place_chips(base, profile, chip_specs):
variant = profile['chip_variant']
if variant == 0:
coords = [(PANEL_W - 88 - i * 92, PANEL_H - 44) for i in range(len(chip_specs))]
elif variant == 1:
coords = [(22 + i * 94, PANEL_H - 44) for i in range(len(chip_specs))]
elif variant == 2:
coords = [(PANEL_W - 110, PANEL_H - 46 - i * 40) for i in range(len(chip_specs))]
else:
coords = [(24 + i * 82, PANEL_H - 50 - (i % 2) * 12) for i in range(len(chip_specs))]
for (txt, accent, fg), (x, y) in zip(chip_specs, coords):
chip = create_chip(txt, accent, fg, 0.96)
base.alpha_composite(chip, (x, y))
def compose_panel(scene, scene_type, local_t, scene_progress, profile):
pal = profile['palette']
accents = pal['accents']
base = Image.new('RGBA', (PANEL_W, PANEL_H), (0, 0, 0, 0))
base.alpha_composite(create_shadow((PANEL_W, PANEL_H), blur_radius=26, alpha=142), (0, 0))
base.alpha_composite(create_back_plate(profile, scene_progress), (0, 0))
base.alpha_composite(create_glass_panel(profile), (0, 0))
d = ImageDraw.Draw(base)
# 顶部标签结构变化
tag_mode = profile['tag_variant']
if tag_mode == 0:
rounded(d, (18, 16, 76, 40), 12, fill=accents[2][:3] + (210,))
d.text((30, 22), 'AI', font=font(12, 'medium'), fill=WHITE)
d.text((90, 22), '卡若式视频增强', font=font(12, 'regular'), fill=pal['sub'])
elif tag_mode == 1:
rounded(d, (18, 16, 108, 40), 12, fill=(255, 255, 255, 20), outline=accents[0][:3] + (120,), width=1)
d.text((30, 22), 'DYNAMIC UI', font=font(11, 'medium'), fill=pal['text'])
d.text((130, 22), pal['name'], font=font(11, 'regular'), fill=pal['muted'])
else:
rounded(d, (18, 16, 84, 40), 12, fill=(20, 24, 38, 190), outline=accents[1][:3] + (120,), width=1)
d.text((28, 22), 'FLOW', font=font(12, 'medium'), fill=pal['text'])
rounded(d, (94, 16, 152, 40), 12, fill=(255, 255, 255, 15), outline=(255, 255, 255, 30), width=1)
d.text((109, 22), 'AUTO', font=font(12, 'medium'), fill=pal['sub'])
place_video_window(base, create_video_window(scene_type, local_t, profile), profile)
variant = profile['content_variant']
if scene_type == 'title_card':
question = scene['params']['question']
subtitle = scene['params'].get('subtitle', '')
typed_ratio = ease_out(min(1.0, local_t / 1.8))
chars = max(1, int(len(question) * typed_ratio))
q_text = question[:chars]
qx = 24 if variant != 1 else 34
qy = 74 if variant == 0 else 86
draw_wrap(d, q_text, font(21, 'medium'), 238, qx, qy, pal['title'])
if typed_ratio < 1:
cursor_x = qx + min(238, int(typed_ratio * 220))
d.text((cursor_x, qy + 36), '', font=font(16, 'regular'), fill=accents[0])
if local_t > 1.0:
alpha = ease_out(min(1.0, (local_t - 1.0) / 0.9))
d.text((qx, qy + 72), subtitle, font=font(13, 'regular'), fill=(pal['sub'][0], pal['sub'][1], pal['sub'][2], int(255 * alpha)))
chip_texts = ['全平台', '自动承接', '后端变现'] if '发视频' in subtitle or '全链路' in subtitle else ['传统行业', '远程安装', '副业服务']
chip_specs = [(txt, accents[i % len(accents)], pal['title']) for i, txt in enumerate(chip_texts)]
place_chips(base, profile, chip_specs)
elif scene_type == 'comparison_card':
params = scene['params']
d.text((22, 70), params['title'], font=font(15, 'medium'), fill=pal['title'])
if variant == 0:
left_box, right_box = (22, 102, 193, 286), (205, 102, 396, 286)
elif variant == 1:
left_box, right_box = (22, 118, 188, 292), (194, 94, 396, 278)
else:
left_box, right_box = (22, 104, 210, 270), (186, 128, 396, 294)
rounded(d, left_box, 18, fill=(39, 19, 28, 200), outline=(248, 113, 113, 120), width=1)
rounded(d, right_box, 18, fill=(18, 42, 31, 200), outline=(52, 211, 153, 120), width=1)
d.text((left_box[0] + 14, left_box[1] + 14), params['left_title'], font=font(13, 'medium'), fill=RED)
d.text((right_box[0] + 14, right_box[1] + 14), params['right_title'], font=font(13, 'medium'), fill=GREEN)
for i, item in enumerate(params['left_items']):
alpha = ease_out(min(1.0, max(0.0, (local_t - 0.35 - i * 0.15) / 0.5)))
if alpha > 0:
y = left_box[1] + 46 + i * 34
d.text((left_box[0] + 14 - int((1 - alpha) * 14), y), f'{item}', font=font(13, 'regular'), fill=(248, 113, 113, int(220 * alpha)))
for i, item in enumerate(params['right_items']):
alpha = ease_out(min(1.0, max(0.0, (local_t - 0.65 - i * 0.15) / 0.5)))
if alpha > 0:
y = right_box[1] + 46 + i * 34
d.text((right_box[0] + 14 + int((1 - alpha) * 14), y), f'{item}', font=font(13, 'regular'), fill=(52, 211, 153, int(220 * alpha)))
elif scene_type == 'data_card':
params = scene['params']
d.text((22, 70), params['title'], font=font(15, 'medium'), fill=pal['title'])
if variant == 0:
positions = [(22, 102), (148, 102), (22, 196), (148, 196)]
elif variant == 1:
positions = [(22, 110), (148, 94), (22, 204), (148, 188)]
else:
positions = [(30, 104), (164, 112), (22, 202), (156, 194)]
for i, item in enumerate(params['items']):
card_t = ease_out(min(1.0, max(0.0, (local_t - 0.22 - i * 0.12) / 0.52)))
if card_t <= 0:
continue
raw = str(item['number'])
value = raw
if '~' in raw:
try:
lo, hi = raw.split('~')
value = f"{int(int(lo) * card_t)}~{int(int(hi) * card_t)}"
except Exception:
pass
elif raw.endswith('万+'):
try:
num = int(raw[:-2])
value = f"{max(1, int(num * card_t))}万+"
except Exception:
pass
elif raw.endswith('分钟'):
try:
num = int(raw[:-2])
value = f"{max(1, int(num * card_t))}分钟"
except Exception:
pass
metric = create_metric_card(item['label'], value, item['desc'], accents[i % len(accents)], profile, local_t)
mx, my = positions[i]
base.alpha_composite(metric, (mx, my - int((1 - card_t) * 10)))
elif scene_type == 'flow_chart':
params = scene['params']
d.text((22, 70), params['title'], font=font(15, 'medium'), fill=pal['title'])
start_y = 112 if variant != 2 else 102
step_gap = 42 if variant == 0 else 38
for i, step in enumerate(params['steps']):
st = ease_out(min(1.0, max(0.0, (local_t - 0.16 - i * 0.17) / 0.52)))
if st <= 0:
continue
y = start_y + i * step_gap
accent = accents[i % len(accents)]
cx = 38 if variant != 1 else 54
cy = y + 12
d.ellipse((cx - 12, cy - 12, cx + 12, cy + 12), fill=accent[:3] + (220,))
d.text((cx - 4, cy - 8), str(i + 1), font=font(12, 'medium'), fill=(255, 255, 255, int(255 * st)))
d.text((cx + 26 + int((1 - st) * 16), y), step, font=font(14, 'regular'), fill=(pal['text'][0], pal['text'][1], pal['text'][2], int(255 * st)))
if i < len(params['steps']) - 1:
for dy in range(22, step_gap - 2, 5):
d.ellipse((cx - 1, y + dy, cx + 1, y + dy + 2), fill=(255, 255, 255, 46))
elif scene_type == 'mindmap_card':
params = scene['params']
cx, cy = (150, 188) if variant == 0 else ((124, 200) if variant == 1 else (140, 178))
center_t = ease_out(min(1.0, local_t / 0.8))
r = 32 + int(4 * math.sin(local_t * 2))
d.ellipse((cx - r, cy - r, cx + r, cy + r), fill=accents[2][:3] + (int(220 * center_t),))
bb = font(14, 'medium').getbbox(params['center'])
d.text((cx - (bb[2] - bb[0]) // 2, cy - 8), params['center'], font=font(14, 'medium'), fill=WHITE)
branches = params['branches']
angle_step = 42 if variant == 0 else (34 if variant == 1 else 48)
start_angle = -100 if variant != 2 else -120
for i, br in enumerate(branches):
bt = ease_out(min(1.0, max(0.0, (local_t - 0.36 - i * 0.11) / 0.56)))
if bt <= 0:
continue
ang = math.radians(start_angle + i * angle_step)
dist = 92 * bt
bx = cx + int(math.cos(ang) * dist)
by = cy + int(math.sin(ang) * dist)
accent = accents[i % len(accents)]
d.line((cx, cy, bx, by), fill=(accent[0], accent[1], accent[2], int(130 * bt)), width=2)
ff = font(11, 'medium')
bbox = ff.getbbox(br)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
rounded(d, (bx - tw // 2 - 8, by - th // 2 - 6, bx + tw // 2 + 8, by + th // 2 + 6), 10,
fill=(accent[0], accent[1], accent[2], 40), outline=(accent[0], accent[1], accent[2], 120), width=1)
d.text((bx - tw // 2, by - th // 2), br, font=ff, fill=(accent[0], accent[1], accent[2], 255))
else:
params = scene['params']
rounded(d, (22, 60, PANEL_W - 22, 114), 22, fill=(18, 27, 44, 220), outline=accents[0][:3] + (110,), width=1)
draw_wrap_center(d, params['headline'], font(21, 'medium'), PANEL_W - 80, 74, pal['title'], PANEL_W)
y = 132
for i, item in enumerate(params['points']):
alpha = ease_out(min(1.0, max(0.0, (local_t - 0.35 - i * 0.13) / 0.48)))
if alpha <= 0:
continue
accent = accents[i % len(accents)]
d.ellipse((30, y + i * 34 + 6, 38, y + i * 34 + 14), fill=(accent[0], accent[1], accent[2], int(220 * alpha)))
d.text((48 + int((1 - alpha) * 12), y + i * 34), item, font=font(14, 'regular'), fill=(pal['text'][0], pal['text'][1], pal['text'][2], int(255 * alpha)))
cta_t = ease_out(min(1.0, max(0.0, (local_t - 1.1) / 0.45)))
if cta_t > 0:
rounded(d, (42, 286 - int((1 - cta_t) * 8), PANEL_W - 42, 316 - int((1 - cta_t) * 8)), 16, fill=accents[2][:3] + (int(220 * cta_t),))
draw_center(d, params['cta'], font(13, 'medium'), 293 - int((1 - cta_t) * 8), WHITE, PANEL_W)
chip_specs = [
('内容引擎', accents[0], pal['title']),
('自动分发', accents[1], pal['title']),
('后端承接', accents[2], pal['title']),
]
if '全链路' in json.dumps(scene.get('params', {}), ensure_ascii=False):
chip_specs = [('全平台', accents[0], pal['title']), ('小程序', accents[1], pal['title']), ('变现链路', accents[2], pal['title'])]
place_chips(base, profile, chip_specs)
return base
def render_overlay_frame(scene, local_t, profile):
scene_progress = (local_t % 6.0) / 6.0
panel = compose_panel(scene, scene['type'], local_t, scene_progress, profile)
intro = ease_out(min(1.0, local_t / 0.65))
breath = 1 + math.sin(local_t * 1.35) * 0.013
scale = (0.935 + intro * 0.07) * breath
y_offset = int((1 - intro) * 18 + math.sin(local_t * 1.05) * 5)
x_offset = int(math.sin(local_t * 0.75) * 3)
panel_resized = panel.resize((int(PANEL_W * scale), int(PANEL_H * scale)), Image.LANCZOS)
frame = Image.new('RGBA', (VW, VH), (0, 0, 0, 0))
px = (VW - panel_resized.width) // 2 + x_offset
py = PANEL_Y - (panel_resized.height - PANEL_H) // 2 + y_offset
frame.alpha_composite(panel_resized, (px, py))
return frame
DEFAULT_SCENES = [
{'start': 0, 'end': 30, 'type': 'title_card', 'params': {'question': '帮别人装AI一单能挣多少钱', 'subtitle': '传统行业也能做的AI副业'}},
{'start': 30, 'end': 70, 'type': 'comparison_card', 'params': {'title': '营销号 vs 真实情况', 'left_title': '营销号', 'left_items': ['AI暴富神话', '零成本躺赚', '一夜翻身'], 'right_title': '真实可做', 'right_items': ['帮装AI工具', '卖API接口', '远程安装服务']}},
{'start': 70, 'end': 130, 'type': 'data_card', 'params': {'title': '装AI服务 · 核心数据', 'items': [{'number': '300~1000', 'label': '元/单', 'desc': '远程安装AI工具'}, {'number': '170万+', 'label': '淘宝月销最高', 'desc': '帮别人装AI的店铺'}, {'number': '30分钟', 'label': '单次耗时', 'desc': '远程操作即可完成'}, {'number': '全平台', 'label': '淘宝/闲鱼/Soul', 'desc': '多渠道接单'}]}},
{'start': 130, 'end': 190, 'type': 'flow_chart', 'params': {'title': '装AI赚钱 · 操作步骤', 'steps': ['开淘宝/闲鱼店铺', '标题写清AI安装服务', '客户下单 远程连接', '30分钟完成安装', '收款300~1000元']}},
{'start': 190, 'end': 230, 'type': 'mindmap_card', 'params': {'center': '装AI副业', 'branches': ['淘宝开店', '闲鱼挂单', 'Soul接客', '远程安装', 'Mac工具', '月入可观']}},
{'start': 230, 'end': 245, 'type': 'summary_card', 'params': {'headline': '赚钱没那么复杂', 'points': ['帮人装AI 一单300~1000', '淘宝最高店月销170万+', '30分钟远程安装搞定', '开店+接单+装机=副业'], 'cta': '关注了解更多AI副业'}},
]
def render_scene_video(scene, tmp_dir, idx):
duration = float(scene['end'] - scene['start'])
scene_dir = os.path.join(tmp_dir, f'scene_{idx:03d}')
os.makedirs(scene_dir, exist_ok=True)
frames = max(1, int(duration * FPS))
concat_lines = []
profile = build_profile(scene, idx)
print(f" [{idx+1}] {scene['type']} {scene['start']:.0f}s-{scene['end']:.0f}s ({frames} 帧, {profile['palette']['name']})...", end='', flush=True)
last_fp = None
for i in range(frames):
local_t = i / FPS
frame = render_overlay_frame(scene, local_t, profile)
fp = os.path.join(scene_dir, f'f_{i:04d}.png')
frame.save(fp, 'PNG')
concat_lines.append(f"file '{fp}'")
concat_lines.append(f'duration {1.0 / FPS:.4f}')
last_fp = fp
concat_lines.append(f"file '{last_fp}'")
concat_path = os.path.join(scene_dir, 'concat.txt')
with open(concat_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(concat_lines))
mov_path = os.path.join(scene_dir, 'scene.mov')
cmd = ['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_path, '-vf', 'fps=25,format=rgba', '-c:v', 'png', '-t', f'{duration:.3f}', mov_path]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" ERR {result.stderr[-600:]}", flush=True)
return None
print('', flush=True)
return {'path': mov_path, 'start': scene['start'], 'end': scene['end']}
def build_full_overlay(scene_videos, duration, tmp_dir):
blank = Image.new('RGBA', (VW, VH), (0, 0, 0, 0))
blank_path = os.path.join(tmp_dir, 'blank.png')
blank.save(blank_path, 'PNG')
concat_lines = []
prev = 0.0
for sv in scene_videos:
if sv['start'] > prev + 0.04:
gap = sv['start'] - prev
concat_lines.append(f"file '{blank_path}'")
concat_lines.append(f'duration {gap:.3f}')
concat_lines.append(f"file '{sv['path']}'")
prev = sv['end']
if prev < duration:
concat_lines.append(f"file '{blank_path}'")
concat_lines.append(f'duration {duration - prev:.3f}')
concat_lines.append(f"file '{blank_path}'")
concat_path = os.path.join(tmp_dir, 'overlay_concat.txt')
with open(concat_path, 'w', encoding='utf-8') as f:
f.write('\n'.join(concat_lines))
out_path = os.path.join(tmp_dir, 'overlay_full.mov')
cmd = ['ffmpeg', '-y', '-f', 'concat', '-safe', '0', '-i', concat_path, '-vf', 'fps=25,format=rgba', '-c:v', 'png', '-t', f'{duration:.3f}', out_path]
print(' 拼接完整叠加流...', end='', flush=True)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" ERR {result.stderr[-800:]}", flush=True)
return None
mb = os.path.getsize(out_path) / 1024 / 1024
print(f' ✓ ({mb:.0f}MB)', flush=True)
return out_path
def compose_final(input_video, overlay_video, output_path, duration):
cmd = ['ffmpeg', '-y', '-i', input_video, '-i', overlay_video, '-filter_complex', '[1:v]format=rgba[ov];[0:v][ov]overlay=0:0:format=auto:shortest=1[v]', '-map', '[v]', '-map', '0:a?', '-c:v', 'libx264', '-preset', 'medium', '-crf', '20', '-c:a', 'aac', '-b:a', '128k', '-t', f'{duration:.3f}', '-movflags', '+faststart', output_path]
print(' 合成最终视频...', end='', flush=True)
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" ERR {result.stderr[-1200:]}", flush=True)
return False
mb = os.path.getsize(output_path) / 1024 / 1024
print(f' ✓ ({mb:.1f}MB)', flush=True)
return True
def get_duration(video_path):
result = subprocess.run(['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', video_path], capture_output=True, text=True)
return float(json.loads(result.stdout)['format']['duration'])
def main():
global CURRENT_VIDEO_SEED
parser = argparse.ArgumentParser(description='视觉增强 v6非固定结构高级玻璃舞台')
parser.add_argument('-i', '--input', required=True)
parser.add_argument('-o', '--output', required=True)
parser.add_argument('--scenes')
args = parser.parse_args()
CURRENT_VIDEO_SEED = Path(args.input).stem
scenes = DEFAULT_SCENES
if args.scenes and os.path.exists(args.scenes):
with open(args.scenes, 'r', encoding='utf-8') as f:
scenes = json.load(f)
duration = get_duration(args.input)
for scene in scenes:
scene['end'] = min(scene['end'], duration)
os.makedirs(os.path.dirname(args.output) or '.', exist_ok=True)
print(f"输入: {os.path.basename(args.input)} ({duration:.0f}s)")
print(f"场景: {len(scenes)} · 非固定结构 · 每视频独立风格\n")
with tempfile.TemporaryDirectory(prefix='ve6_') as tmp_dir:
print('【1/3】生成每段动态舞台...', flush=True)
scene_videos = []
for idx, scene in enumerate(scenes):
scene_video = render_scene_video(scene, tmp_dir, idx)
if scene_video:
scene_videos.append(scene_video)
print(f"\n【2/3】拼接叠加流 ({len(scene_videos)} 段)...", flush=True)
overlay = build_full_overlay(scene_videos, duration, tmp_dir)
if not overlay:
sys.exit(1)
print('\n【3/3】合成最终视频...', flush=True)
if not compose_final(args.input, overlay, args.output, duration):
sys.exit(1)
print(f"\n✅ 完成: {args.output}")
if __name__ == '__main__':
main()