2026-03-11 14:38:09 +08:00
|
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
|
"""
|
2026-03-13 13:50:26 +08:00
|
|
|
|
视觉增强 v8:苹果毛玻璃底部浮层(终版)
|
|
|
|
|
|
|
|
|
|
|
|
改动:
|
|
|
|
|
|
- 去掉所有图标 badge、问号圆圈、Unicode 图标前缀、白块
|
|
|
|
|
|
- 左上角加载卡若创业派对 Logo + "第 N 场"
|
|
|
|
|
|
- 场景按主题段落切换:开头提问 → 中间要点总结 → 结尾 CTA
|
|
|
|
|
|
- 配色与 Soul 绿协调的青绿色系
|
|
|
|
|
|
- 芯片改为渐变描边胶囊
|
2026-03-11 14:38:09 +08:00
|
|
|
|
"""
|
|
|
|
|
|
import argparse
|
2026-03-11 14:55:09 +08:00
|
|
|
|
import hashlib
|
2026-03-11 14:38:09 +08:00
|
|
|
|
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:
|
2026-03-13 10:42:23 +08:00
|
|
|
|
sys.exit("pip3 install Pillow")
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
2026-03-13 13:50:26 +08:00
|
|
|
|
SKILL_DIR = SCRIPT_DIR.parent
|
|
|
|
|
|
FONTS_DIR = SKILL_DIR / "fonts"
|
2026-03-11 14:38:09 +08:00
|
|
|
|
if not FONTS_DIR.exists():
|
2026-03-13 10:42:23 +08:00
|
|
|
|
FONTS_DIR = Path("/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/视频切片/fonts")
|
2026-03-13 13:50:26 +08:00
|
|
|
|
LOGO_PATH = SKILL_DIR / "参考资料" / "karuo_logo.png"
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
|
|
|
|
|
VW, VH = 498, 1080
|
2026-03-13 13:50:26 +08:00
|
|
|
|
PANEL_W, PANEL_H = 428, 310
|
2026-03-11 14:38:09 +08:00
|
|
|
|
PANEL_X = (VW - PANEL_W) // 2
|
2026-03-13 10:42:23 +08:00
|
|
|
|
PANEL_Y = VH - PANEL_H - 30
|
2026-03-11 14:38:09 +08:00
|
|
|
|
FPS = 8
|
2026-03-13 10:42:23 +08:00
|
|
|
|
CURRENT_SEED = "default"
|
|
|
|
|
|
|
2026-03-13 15:42:52 +08:00
|
|
|
|
GLASS_TOP = (10, 12, 22, 230)
|
|
|
|
|
|
GLASS_BTM = (6, 8, 16, 230)
|
|
|
|
|
|
GLASS_BORDER = (255, 255, 255, 18)
|
|
|
|
|
|
GLASS_INNER = (255, 255, 255, 8)
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
SOUL_GREEN = (0, 210, 106)
|
|
|
|
|
|
ACCENT_A = (0, 200, 140, 255)
|
|
|
|
|
|
ACCENT_B = (52, 211, 238, 255)
|
|
|
|
|
|
|
|
|
|
|
|
TEXT_PRI = (240, 244, 255, 255)
|
|
|
|
|
|
TEXT_SEC = (163, 177, 206, 255)
|
|
|
|
|
|
TEXT_MUT = (100, 116, 145, 255)
|
|
|
|
|
|
WHITE = (248, 250, 255, 255)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
_logo_cache = None
|
2026-03-13 10:42:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def _load_logo(height=26):
|
|
|
|
|
|
global _logo_cache
|
|
|
|
|
|
if _logo_cache is not None:
|
|
|
|
|
|
return _logo_cache
|
|
|
|
|
|
if LOGO_PATH.exists():
|
|
|
|
|
|
try:
|
|
|
|
|
|
img = Image.open(str(LOGO_PATH)).convert("RGBA")
|
|
|
|
|
|
ratio = height / img.height
|
|
|
|
|
|
new_w = int(img.width * ratio)
|
|
|
|
|
|
_logo_cache = img.resize((new_w, height), Image.LANCZOS)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
_logo_cache = None
|
|
|
|
|
|
else:
|
|
|
|
|
|
_logo_cache = None
|
|
|
|
|
|
return _logo_cache
|
2026-03-13 10:42:23 +08:00
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
|
|
|
|
|
|
def font(size, weight="medium"):
|
2026-03-11 14:55:09 +08:00
|
|
|
|
mapping = {
|
2026-03-13 13:50:26 +08:00
|
|
|
|
"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")],
|
2026-03-11 14:45:52 +08:00
|
|
|
|
}
|
2026-03-13 13:50:26 +08:00
|
|
|
|
for p in mapping.get(weight, mapping["medium"]):
|
|
|
|
|
|
if p.exists():
|
2026-03-11 14:38:09 +08:00
|
|
|
|
try:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
return ImageFont.truetype(str(p), size)
|
2026-03-11 14:38:09 +08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
return ImageFont.load_default()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def ease_out(t):
|
2026-03-11 14:38:09 +08:00
|
|
|
|
t = max(0.0, min(1.0, t))
|
2026-03-11 14:55:09 +08:00
|
|
|
|
return 1 - (1 - t) ** 3
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def blend(c1, c2, t):
|
2026-03-13 10:42:23 +08:00
|
|
|
|
return tuple(int(a + (b - a) * max(0, min(1, t))) for a, b in zip(c1, c2))
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
2026-03-11 14:55:09 +08:00
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def tw(f, t):
|
|
|
|
|
|
bb = f.getbbox(t)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
return bb[2] - bb[0]
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def th(f, t):
|
|
|
|
|
|
bb = f.getbbox(t)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
return bb[3] - bb[1]
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
2026-03-13 10:42:23 +08:00
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def draw_center(d, text, f, y, fill, cw=PANEL_W):
|
|
|
|
|
|
d.text(((cw - tw(f, text)) // 2, y), text, font=f, fill=fill)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def draw_wrap(d, text, f, max_w, x, y, fill, gap=6):
|
2026-03-13 10:42:23 +08:00
|
|
|
|
lines, cur = [], ""
|
2026-03-11 14:38:09 +08:00
|
|
|
|
for ch in text:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
t = cur + ch
|
|
|
|
|
|
if tw(f, t) > max_w and cur:
|
|
|
|
|
|
lines.append(cur); cur = ch
|
2026-03-11 14:38:09 +08:00
|
|
|
|
else:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
cur = t
|
2026-03-11 14:38:09 +08:00
|
|
|
|
if cur:
|
|
|
|
|
|
lines.append(cur)
|
|
|
|
|
|
for line in lines:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
d.text((x, y), line, font=f, fill=fill)
|
|
|
|
|
|
y += th(f, line) + gap
|
2026-03-11 14:38:09 +08:00
|
|
|
|
return y
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def draw_wrap_center(d, text, f, max_w, y, fill, cw=PANEL_W, gap=6):
|
2026-03-13 10:42:23 +08:00
|
|
|
|
lines, cur = [], ""
|
2026-03-11 14:38:09 +08:00
|
|
|
|
for ch in text:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
t = cur + ch
|
|
|
|
|
|
if tw(f, t) > max_w and cur:
|
|
|
|
|
|
lines.append(cur); cur = ch
|
2026-03-11 14:38:09 +08:00
|
|
|
|
else:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
cur = t
|
2026-03-11 14:38:09 +08:00
|
|
|
|
if cur:
|
|
|
|
|
|
lines.append(cur)
|
|
|
|
|
|
for line in lines:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
draw_center(d, line, f, y, fill, cw)
|
|
|
|
|
|
y += th(f, line) + gap
|
2026-03-11 14:38:09 +08:00
|
|
|
|
return y
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def _shadow():
|
2026-03-13 10:42:23 +08:00
|
|
|
|
img = Image.new("RGBA", (PANEL_W, PANEL_H), (0, 0, 0, 0))
|
2026-03-11 14:38:09 +08:00
|
|
|
|
d = ImageDraw.Draw(img)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
d.rounded_rectangle((14, 20, PANEL_W - 16, PANEL_H - 4), radius=28, fill=(0, 0, 0, 150))
|
|
|
|
|
|
return img.filter(ImageFilter.GaussianBlur(22))
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def _glass():
|
2026-03-13 10:42:23 +08:00
|
|
|
|
img = Image.new("RGBA", (PANEL_W, PANEL_H), (0, 0, 0, 0))
|
|
|
|
|
|
grad = Image.new("RGBA", (PANEL_W, PANEL_H), (0, 0, 0, 0))
|
2026-03-11 14:38:09 +08:00
|
|
|
|
gd = ImageDraw.Draw(grad)
|
|
|
|
|
|
for y in range(PANEL_H):
|
|
|
|
|
|
t = y / max(PANEL_H - 1, 1)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
gd.line([(0, y), (PANEL_W, y)], fill=blend(GLASS_TOP, GLASS_BTM, t))
|
2026-03-13 10:42:23 +08:00
|
|
|
|
mask = Image.new("L", (PANEL_W, PANEL_H), 0)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
ImageDraw.Draw(mask).rounded_rectangle((0, 0, PANEL_W - 1, PANEL_H - 1), radius=26, fill=255)
|
2026-03-11 14:38:09 +08:00
|
|
|
|
img = Image.composite(grad, img, mask)
|
|
|
|
|
|
d = ImageDraw.Draw(img)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
d.rounded_rectangle((0, 0, PANEL_W - 1, PANEL_H - 1), radius=26, outline=GLASS_BORDER, width=1)
|
|
|
|
|
|
d.rounded_rectangle((3, 3, PANEL_W - 4, PANEL_H - 4), radius=24, outline=GLASS_INNER, width=1)
|
|
|
|
|
|
for y in range(8):
|
|
|
|
|
|
a = int(20 * (1 - y / 8))
|
|
|
|
|
|
d.line([(20, y + 4), (PANEL_W - 20, y + 4)], fill=(255, 255, 255, a))
|
2026-03-11 14:38:09 +08:00
|
|
|
|
return img
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def _chip(text, active=1.0):
|
|
|
|
|
|
pad = 12
|
|
|
|
|
|
ff = font(12, "medium")
|
|
|
|
|
|
w = max(60, tw(ff, text) + pad * 2)
|
|
|
|
|
|
h = 28
|
2026-03-13 10:42:23 +08:00
|
|
|
|
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
|
2026-03-11 14:38:09 +08:00
|
|
|
|
d = ImageDraw.Draw(img)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
border = blend(ACCENT_A, ACCENT_B, 0.5)[:3] + (int(140 * active),)
|
|
|
|
|
|
d.rounded_rectangle((0, 0, w - 1, h - 1), radius=14, fill=(255, 255, 255, int(10 * active)), outline=border, width=1)
|
|
|
|
|
|
d.text((pad, (h - th(ff, text)) // 2 - 1), text, font=ff, fill=(TEXT_PRI[0], TEXT_PRI[1], TEXT_PRI[2], int(255 * active)))
|
2026-03-11 14:38:09 +08:00
|
|
|
|
return img
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def _place_chips(base, chips, t):
|
|
|
|
|
|
gap = 8
|
|
|
|
|
|
imgs = [_chip(c) for c in chips]
|
|
|
|
|
|
total = sum(im.width for im in imgs) + gap * (len(imgs) - 1)
|
|
|
|
|
|
x = (PANEL_W - total) // 2
|
|
|
|
|
|
y = PANEL_H - 38
|
|
|
|
|
|
for i, im in enumerate(imgs):
|
|
|
|
|
|
ct = ease_out(max(0, min(1, (t - 0.6 - i * 0.1) / 0.35)))
|
|
|
|
|
|
if ct > 0:
|
|
|
|
|
|
base.alpha_composite(im, (x, y + int((1 - ct) * 8)))
|
2026-03-13 10:42:23 +08:00
|
|
|
|
x += im.width + gap
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def _header(base, episode, sub_label, t):
|
2026-03-13 10:42:23 +08:00
|
|
|
|
d = ImageDraw.Draw(base)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
logo = _load_logo(24)
|
|
|
|
|
|
x = 18
|
|
|
|
|
|
if logo:
|
|
|
|
|
|
la = ease_out(min(1.0, t * 3))
|
|
|
|
|
|
if la > 0.1:
|
|
|
|
|
|
base.alpha_composite(logo, (x, 16))
|
|
|
|
|
|
x += logo.width + 8
|
|
|
|
|
|
|
|
|
|
|
|
ff = font(11, "regular")
|
|
|
|
|
|
ep_text = f"第 {episode} 场" if episode else ""
|
|
|
|
|
|
la = ease_out(min(1.0, max(0, (t - 0.1) / 0.4)))
|
|
|
|
|
|
if la > 0 and ep_text:
|
|
|
|
|
|
d.text((x, 20), ep_text, font=ff, fill=(TEXT_SEC[0], TEXT_SEC[1], TEXT_SEC[2], int(200 * la)))
|
|
|
|
|
|
|
|
|
|
|
|
if sub_label:
|
|
|
|
|
|
sa = ease_out(min(1.0, max(0, (t - 0.15) / 0.4)))
|
|
|
|
|
|
if sa > 0:
|
|
|
|
|
|
fs = font(10, "regular")
|
|
|
|
|
|
d.text((x, 36), sub_label, font=fs, fill=(TEXT_MUT[0], TEXT_MUT[1], TEXT_MUT[2], int(170 * sa)))
|
|
|
|
|
|
|
|
|
|
|
|
dot_r = 3 + int(1 * math.sin(t * 3))
|
|
|
|
|
|
dc = ACCENT_A[:3] + (180,)
|
|
|
|
|
|
d.ellipse((PANEL_W - 26 - dot_r, 22 - dot_r, PANEL_W - 26 + dot_r, 22 + dot_r), fill=dc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _render_title(base, params, t):
|
2026-03-13 10:42:23 +08:00
|
|
|
|
d = ImageDraw.Draw(base)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
q = params.get("question", "")
|
|
|
|
|
|
sub = params.get("subtitle", "")
|
|
|
|
|
|
ff = font(20, "medium")
|
|
|
|
|
|
type_t = ease_out(min(1.0, t / 1.8))
|
|
|
|
|
|
chars = max(1, int(len(q) * type_t))
|
|
|
|
|
|
shown = q[:chars]
|
2026-03-13 10:42:23 +08:00
|
|
|
|
cursor = "▍" if type_t < 0.95 else ""
|
2026-03-13 13:50:26 +08:00
|
|
|
|
draw_wrap(d, shown + cursor, ff, PANEL_W - 40, 20, 62, TEXT_PRI)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
if sub and t > 1.0:
|
|
|
|
|
|
sa = ease_out(min(1.0, (t - 1.0) / 0.7))
|
2026-03-13 10:42:23 +08:00
|
|
|
|
fs = font(13, "regular")
|
2026-03-13 13:50:26 +08:00
|
|
|
|
d.text((20, 120), sub, font=fs, fill=(TEXT_SEC[0], TEXT_SEC[1], TEXT_SEC[2], int(200 * sa)))
|
2026-03-13 10:42:23 +08:00
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
_place_chips(base, params.get("chips", []), t)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def _render_summary(base, params, t):
|
2026-03-13 10:42:23 +08:00
|
|
|
|
d = ImageDraw.Draw(base)
|
|
|
|
|
|
headline = params.get("headline", "")
|
|
|
|
|
|
points = params.get("points", [])
|
|
|
|
|
|
cta = params.get("cta", "")
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
ha = ease_out(min(1.0, max(0, t / 0.5)))
|
2026-03-13 10:42:23 +08:00
|
|
|
|
if ha > 0:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
d.rounded_rectangle((16, 56, PANEL_W - 16, 96), radius=18,
|
|
|
|
|
|
fill=blend(ACCENT_A, ACCENT_B, 0.5)[:3] + (int(20 * ha),),
|
|
|
|
|
|
outline=blend(ACCENT_A, ACCENT_B, 0.5)[:3] + (int(80 * ha),), width=1)
|
|
|
|
|
|
draw_wrap_center(d, headline, font(18, "medium"), PANEL_W - 60, 66, TEXT_PRI)
|
|
|
|
|
|
|
|
|
|
|
|
y = 110
|
2026-03-13 10:42:23 +08:00
|
|
|
|
for i, pt in enumerate(points):
|
2026-03-13 13:50:26 +08:00
|
|
|
|
ia = ease_out(min(1.0, max(0, (t - 0.25 - i * 0.1) / 0.45)))
|
2026-03-13 10:42:23 +08:00
|
|
|
|
if ia <= 0:
|
|
|
|
|
|
continue
|
2026-03-13 13:50:26 +08:00
|
|
|
|
ac = blend(ACCENT_A, ACCENT_B, i / max(len(points) - 1, 1))
|
|
|
|
|
|
d.ellipse((22, y + i * 30 + 5, 28, y + i * 30 + 11), fill=ac[:3] + (int(220 * ia),))
|
|
|
|
|
|
fp = font(13, "regular")
|
|
|
|
|
|
d.text((36 + int((1 - ia) * 10), y + i * 30), pt, font=fp,
|
|
|
|
|
|
fill=(TEXT_PRI[0], TEXT_PRI[1], TEXT_PRI[2], int(250 * ia)))
|
|
|
|
|
|
|
2026-03-13 10:42:23 +08:00
|
|
|
|
if cta:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
ca = ease_out(min(1.0, max(0, (t - 0.9) / 0.4)))
|
2026-03-13 10:42:23 +08:00
|
|
|
|
if ca > 0:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
by = PANEL_H - 48 - int((1 - ca) * 6)
|
|
|
|
|
|
for x in range(36, PANEL_W - 36):
|
|
|
|
|
|
tf = (x - 36) / max(PANEL_W - 72, 1)
|
|
|
|
|
|
col = blend(ACCENT_A, ACCENT_B, tf)[:3] + (int(200 * ca),)
|
|
|
|
|
|
d.line([(x, by), (x, by + 26)], fill=col)
|
|
|
|
|
|
d.rounded_rectangle((36, by, PANEL_W - 36, by + 26), radius=13,
|
|
|
|
|
|
outline=(255, 255, 255, int(30 * ca)), width=1)
|
|
|
|
|
|
draw_center(d, cta, font(12, "medium"), by + 6, WHITE)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
RENDERERS = {
|
2026-03-13 13:50:26 +08:00
|
|
|
|
"title_card": _render_title,
|
|
|
|
|
|
"summary_card": _render_summary,
|
2026-03-13 10:42:23 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def compose_frame(scene, local_t):
|
2026-03-13 10:42:23 +08:00
|
|
|
|
params = scene.get("params", {})
|
2026-03-13 13:50:26 +08:00
|
|
|
|
episode = scene.get("episode", "")
|
2026-03-13 10:42:23 +08:00
|
|
|
|
sub_label = scene.get("sub_label", "")
|
2026-03-13 13:50:26 +08:00
|
|
|
|
scene_type = scene.get("type", "title_card")
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
base = Image.new("RGBA", (PANEL_W, PANEL_H), (0, 0, 0, 0))
|
|
|
|
|
|
base.alpha_composite(_shadow(), (0, 0))
|
|
|
|
|
|
base.alpha_composite(_glass(), (0, 0))
|
|
|
|
|
|
_header(base, episode, sub_label, local_t)
|
|
|
|
|
|
renderer = RENDERERS.get(scene_type, _render_title)
|
|
|
|
|
|
renderer(base, params, local_t)
|
2026-03-11 14:38:09 +08:00
|
|
|
|
return base
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def render_overlay(scene, local_t):
|
|
|
|
|
|
panel = compose_frame(scene, local_t)
|
|
|
|
|
|
intro = ease_out(min(1.0, local_t / 0.55))
|
|
|
|
|
|
breath = 1 + math.sin(local_t * 1.2) * 0.01
|
2026-03-13 10:42:23 +08:00
|
|
|
|
scale = (0.94 + intro * 0.06) * breath
|
2026-03-13 13:50:26 +08:00
|
|
|
|
yd = int((1 - intro) * 16 + math.sin(local_t * 0.9) * 3)
|
|
|
|
|
|
xd = int(math.sin(local_t * 0.6) * 2)
|
|
|
|
|
|
ps = panel.resize((int(PANEL_W * scale), int(PANEL_H * scale)), Image.LANCZOS)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
frame = Image.new("RGBA", (VW, VH), (0, 0, 0, 0))
|
2026-03-13 13:50:26 +08:00
|
|
|
|
px = (VW - ps.width) // 2 + xd
|
|
|
|
|
|
py = PANEL_Y - (ps.height - PANEL_H) // 2 + yd
|
|
|
|
|
|
frame.alpha_composite(ps, (max(0, px), max(0, py)))
|
2026-03-11 14:38:09 +08:00
|
|
|
|
return frame
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DEFAULT_SCENES = [
|
2026-03-13 13:50:26 +08:00
|
|
|
|
{"start": 0, "end": 30, "type": "title_card", "episode": 121, "sub_label": "Soul创业派对",
|
|
|
|
|
|
"params": {"question": "哪个AI模型才是真正意义上的AI?", "subtitle": "深度AI vs 语言模型", "chips": ["AI评测", "深度AI", "实操"]}},
|
|
|
|
|
|
{"start": 30, "end": 90, "type": "summary_card", "episode": 121, "sub_label": "核心要点",
|
|
|
|
|
|
"params": {"headline": "选AI就选能执行的", "points": ["语言模型只回答文字", "深度AI理解后执行", "先跑一遍再说"], "cta": "关注了解更多"}},
|
2026-03-11 14:38:09 +08:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def render_scene_clip(scene, idx, tmp):
|
2026-03-13 10:42:23 +08:00
|
|
|
|
dur = float(scene["end"] - scene["start"])
|
2026-03-13 13:50:26 +08:00
|
|
|
|
sdir = os.path.join(tmp, f"s{idx:02d}")
|
2026-03-13 10:42:23 +08:00
|
|
|
|
os.makedirs(sdir, exist_ok=True)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
nf = max(1, int(dur * FPS))
|
2026-03-13 10:42:23 +08:00
|
|
|
|
concat = []
|
2026-03-13 13:50:26 +08:00
|
|
|
|
last = None
|
|
|
|
|
|
print(f" [{idx+1}] {scene.get('type','')} {scene['start']:.0f}s-{scene['end']:.0f}s ({nf}f)...", end="", flush=True)
|
|
|
|
|
|
for i in range(nf):
|
2026-03-13 10:42:23 +08:00
|
|
|
|
lt = i / FPS
|
2026-03-13 13:50:26 +08:00
|
|
|
|
frame = render_overlay(scene, lt)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
fp = os.path.join(sdir, f"f{i:04d}.png")
|
|
|
|
|
|
frame.save(fp, "PNG")
|
|
|
|
|
|
concat.append(f"file '{fp}'")
|
2026-03-13 13:50:26 +08:00
|
|
|
|
concat.append(f"duration {1.0 / FPS:.4f}")
|
|
|
|
|
|
last = fp
|
|
|
|
|
|
concat.append(f"file '{last}'")
|
|
|
|
|
|
cf = os.path.join(sdir, "c.txt")
|
2026-03-13 10:42:23 +08:00
|
|
|
|
with open(cf, "w") as f:
|
|
|
|
|
|
f.write("\n".join(concat))
|
2026-03-13 13:50:26 +08:00
|
|
|
|
mov = os.path.join(sdir, "s.mov")
|
|
|
|
|
|
r = subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", cf,
|
|
|
|
|
|
"-vf", "fps=25,format=rgba", "-c:v", "png", "-t", f"{dur:.3f}", mov],
|
|
|
|
|
|
capture_output=True, text=True)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
if r.returncode != 0:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
print(" ERR", flush=True); return None
|
|
|
|
|
|
print(" OK", flush=True)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
return {"path": mov, "start": scene["start"], "end": scene["end"]}
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def build_overlay(clips, duration, tmp):
|
2026-03-13 10:42:23 +08:00
|
|
|
|
blank = Image.new("RGBA", (VW, VH), (0, 0, 0, 0))
|
2026-03-13 13:50:26 +08:00
|
|
|
|
bp = os.path.join(tmp, "b.png")
|
2026-03-13 10:42:23 +08:00
|
|
|
|
blank.save(bp, "PNG")
|
|
|
|
|
|
concat = []
|
2026-03-11 14:38:09 +08:00
|
|
|
|
prev = 0.0
|
2026-03-13 10:42:23 +08:00
|
|
|
|
for c in clips:
|
|
|
|
|
|
if c["start"] > prev + 0.05:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
concat += [f"file '{bp}'", f"duration {c['start'] - prev:.3f}"]
|
2026-03-13 10:42:23 +08:00
|
|
|
|
concat.append(f"file '{c['path']}'")
|
|
|
|
|
|
prev = c["end"]
|
2026-03-11 14:38:09 +08:00
|
|
|
|
if prev < duration:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
concat += [f"file '{bp}'", f"duration {duration - prev:.3f}"]
|
2026-03-13 10:42:23 +08:00
|
|
|
|
concat.append(f"file '{bp}'")
|
2026-03-13 13:50:26 +08:00
|
|
|
|
cf = os.path.join(tmp, "oc.txt")
|
2026-03-13 10:42:23 +08:00
|
|
|
|
with open(cf, "w") as f:
|
|
|
|
|
|
f.write("\n".join(concat))
|
2026-03-13 13:50:26 +08:00
|
|
|
|
out = os.path.join(tmp, "ov.mov")
|
|
|
|
|
|
print(" 叠加流...", end="", flush=True)
|
|
|
|
|
|
r = subprocess.run(["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", cf,
|
|
|
|
|
|
"-vf", "fps=25,format=rgba", "-c:v", "png", "-t", f"{duration:.3f}", out],
|
|
|
|
|
|
capture_output=True, text=True)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
if r.returncode != 0:
|
2026-03-13 13:50:26 +08:00
|
|
|
|
print(" ERR", flush=True); return None
|
|
|
|
|
|
mb = os.path.getsize(out) // 1024 // 1024
|
|
|
|
|
|
print(f" OK ({mb}MB)", flush=True)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def compose_final(inp, ov, outp, dur):
|
|
|
|
|
|
r = subprocess.run([
|
|
|
|
|
|
"ffmpeg", "-y", "-i", inp, "-i", ov,
|
|
|
|
|
|
"-filter_complex", "[1:v]format=rgba[o];[0:v][o]overlay=0:0:format=auto:shortest=1[v]",
|
2026-03-13 10:42:23 +08:00
|
|
|
|
"-map", "[v]", "-map", "0:a?",
|
|
|
|
|
|
"-c:v", "libx264", "-preset", "medium", "-crf", "20",
|
2026-03-13 13:50:26 +08:00
|
|
|
|
"-c:a", "aac", "-b:a", "128k", "-t", f"{dur:.3f}", "-movflags", "+faststart", outp,
|
|
|
|
|
|
], capture_output=True, text=True)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
if r.returncode != 0:
|
2026-03-11 14:38:09 +08:00
|
|
|
|
return False
|
2026-03-13 13:50:26 +08:00
|
|
|
|
mb = os.path.getsize(outp) // 1024 // 1024
|
|
|
|
|
|
print(f" 合成 OK ({mb}MB)", flush=True)
|
2026-03-11 14:38:09 +08:00
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 13:50:26 +08:00
|
|
|
|
def get_dur(v):
|
|
|
|
|
|
r = subprocess.run(["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", v], capture_output=True, text=True)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
return float(json.loads(r.stdout)["format"]["duration"])
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
2026-03-13 10:42:23 +08:00
|
|
|
|
global CURRENT_SEED
|
2026-03-13 13:50:26 +08:00
|
|
|
|
ap = argparse.ArgumentParser(description="视觉增强 v8")
|
2026-03-13 10:42:23 +08:00
|
|
|
|
ap.add_argument("-i", "--input", required=True)
|
|
|
|
|
|
ap.add_argument("-o", "--output", required=True)
|
|
|
|
|
|
ap.add_argument("--scenes")
|
|
|
|
|
|
args = ap.parse_args()
|
|
|
|
|
|
CURRENT_SEED = Path(args.input).stem
|
2026-03-11 14:38:09 +08:00
|
|
|
|
scenes = DEFAULT_SCENES
|
|
|
|
|
|
if args.scenes and os.path.exists(args.scenes):
|
2026-03-13 10:42:23 +08:00
|
|
|
|
with open(args.scenes, "r", encoding="utf-8") as f:
|
2026-03-11 14:38:09 +08:00
|
|
|
|
scenes = json.load(f)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
dur = get_dur(args.input)
|
|
|
|
|
|
for s in scenes:
|
|
|
|
|
|
s["end"] = min(s["end"], dur)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
print(f"输入: {Path(args.input).name} ({dur:.0f}s)")
|
|
|
|
|
|
print(f"场景: {len(scenes)} 段 v8\n")
|
|
|
|
|
|
with tempfile.TemporaryDirectory(prefix="ve8_") as tmp:
|
|
|
|
|
|
print("【1/3】动态帧...", flush=True)
|
|
|
|
|
|
clips = [c for c in (render_scene_clip(s, i, tmp) for i, s in enumerate(scenes)) if c]
|
2026-03-13 10:42:23 +08:00
|
|
|
|
if not clips:
|
|
|
|
|
|
sys.exit(1)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
print(f"\n【2/3】叠加流 ({len(clips)} 段)...", flush=True)
|
|
|
|
|
|
ov = build_overlay(clips, dur, tmp)
|
2026-03-13 10:42:23 +08:00
|
|
|
|
if not ov:
|
2026-03-11 14:38:09 +08:00
|
|
|
|
sys.exit(1)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
print("\n【3/3】合成...", flush=True)
|
|
|
|
|
|
if not compose_final(args.input, ov, args.output, dur):
|
2026-03-11 14:38:09 +08:00
|
|
|
|
sys.exit(1)
|
2026-03-13 13:50:26 +08:00
|
|
|
|
print(f"\n完成: {args.output}")
|
2026-03-11 14:38:09 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 10:42:23 +08:00
|
|
|
|
if __name__ == "__main__":
|
2026-03-11 14:38:09 +08:00
|
|
|
|
main()
|