diff --git a/03_卡木(木)/木叶_视频内容/视频切片/Soul竖屏切片_SKILL.md b/03_卡木(木)/木叶_视频内容/视频切片/Soul竖屏切片_SKILL.md index 50cea72f..7673bf1f 100644 --- a/03_卡木(木)/木叶_视频内容/视频切片/Soul竖屏切片_SKILL.md +++ b/03_卡木(木)/木叶_视频内容/视频切片/Soul竖屏切片_SKILL.md @@ -1,11 +1,11 @@ --- name: Soul竖屏切片 -description: Soul 派对视频→竖屏成片(498×1080),剪辑→成片两文件夹,MLX 转录→高光识别→batch_clip→soul_enhance(封面+字幕+去语助词)。可选 LTX(AI 生成内容、Retake 重剪)衔接成片流程。支持基因胶囊打包。 -triggers: Soul竖屏切片、视频切片、热点切片、竖屏成片、派对切片、LTX、AI生成视频、Retake重剪 +description: Soul 派对视频→竖屏成片(498×1080),剪辑→成片两文件夹,MLX 转录→高光识别→batch_clip→soul_enhance(封面+字幕同步+去语助词+纠错)→visual_enhance v7(苹果毛玻璃浮层)。可选 LTX AI 生成内容/Retake 重剪。支持基因胶囊打包。 +triggers: Soul竖屏切片、视频切片、热点切片、竖屏成片、派对切片、LTX、AI生成视频、Retake重剪、字幕优化、字幕同步 owner: 木叶 group: 木 -version: "1.0" -updated: "2026-02-27" +version: "1.2" +updated: "2026-03-13" --- # Soul 竖屏切片 · 专用 Skill @@ -73,6 +73,10 @@ updated: "2026-02-27" | 字幕全跳过(转录稿异常误判) | `_parse_clip_index` 取到场次号(如 119)而非切片序号(01),导致 highlight_info 为空,start_sec=0 落入噪声区 | 改为取 `_数字_` 模式中**最小值**,119→01=1 ✓ | | 标题/文件名有下划线 | `sanitize_filename` 保留了 `_` | 现在 `_` 也替换为空格 | | 字幕烧录极慢(N/5 次 encode) | 原 batch_size=5,180 条字幕需 36 次 FFmpeg 重编码 | 改为单次通道(1 次 pass);失败时 batch_size=40 兜底 | +| **字幕超前于说话(字幕比声音早)** | `batch_clip -ss` 输入端 seeking 导致切片从关键帧开始(早于请求时间 1-3s),字幕按请求时间算相对位置,导致超前 | `SUBTITLE_DELAY_SEC` 从 0.8 提高到 **2.0 秒**;Soul 派对直播流关键帧间距 2-4s,2.0s 补偿更准确 | +| **封面期间出现字幕** | 字幕时间计算使字幕落在封面段(前 2.5s)内 | `write_clip_srt` 强制过滤 `end <= cover_duration` 的条目,并 `start = max(start, cover_duration)` | +| **字幕含 ASR 噪声行(单字母 L / Agent)** | MLX Whisper 对静音/噪声段产生幻觉字符 | `_is_noise_line()` 提前过滤单字母、重复字符、噪声 token | +| **繁体字幕未转简体** | Soul 派对录音有港台口音,ASR 输出繁体 | `_to_simplified()` 兜底 + CORRECTIONS 扩充 50+ 繁体常用字映射 | --- @@ -131,7 +135,35 @@ xxx_output/ --- -## 九、AI 生成与 LTX 可选集成 +## 九、底部浮层:苹果毛玻璃样式(visual_enhance v7) + +在 `soul_enhance` 的封面+字幕+竖屏成片上,可选叠加苹果毛玻璃底部浮层,作为**最终成片**(不再多一个"增强版"目录)。 + +### 设计规范(来自卡若AI前端 神射手/毛狐狸标准) + +| 元素 | 规格 | +|------|------| +| 背景 | `rgba(14,16,28,0.88)` 深黑半透 + 顶部高光条 | +| 圆角 | 28px(对应前端 `rounded-2xl`) | +| 边框 | `rgba(255,255,255,0.12)` 白边 + 内缩 `rgba(255,255,255,0.06)` | +| 阴影 | GaussianBlur(22),叠加轻层阴影制造悬浮感 | +| 字体 | 标题 Medium,正文 Regular(两档,不堆叠字重) | +| 主色 | 蓝→紫渐变(`from-blue-500 to-purple-500`),单色点睛 | +| 图标 | Unicode 符号图标:◆ 数据 / ▸ 流程 / ⇌ 对比 / ✦ 总结 | +| 芯片 | 渐变描边胶囊(glass-button 风格),不做满色填充 | +| **⚠️ 无视频小窗** | 已永久去掉右上角动态小视频窗,不再加入 | + +### 使用命令 + +```bash +python3 visual_enhance.py -i "soul_enhanced.mp4" -o "成片/标题.mp4" --scenes scenes.json +``` + +`--scenes` JSON 格式:每段需 `type`, `label`, `sub_label`, `params`(含 `question`/`subtitle`/`chips`)。 + +--- + +## 十、AI 生成与 LTX 可选集成 在「已有录播 → 转录→高光→切片→成片」之外,可选用 **LTX** 系能力,实现 **AI 生成视频内容** 与 **在已有视频上轻松重剪**,成片仍走本 Skill 的封面+字幕+竖屏规范。 diff --git a/03_卡木(木)/木叶_视频内容/视频切片/参考资料/karuo_logo.png b/03_卡木(木)/木叶_视频内容/视频切片/参考资料/karuo_logo.png new file mode 100644 index 00000000..c34ed8f5 Binary files /dev/null and b/03_卡木(木)/木叶_视频内容/视频切片/参考资料/karuo_logo.png differ diff --git a/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_enhance.py b/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_enhance.py index 22dd7a81..b133d2d8 100644 --- a/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_enhance.py +++ b/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_enhance.py @@ -89,19 +89,47 @@ def _to_simplified(text: str) -> str: text = text.replace(t, s) return text -# 常见转录错误修正(与 one_video 一致) +# 常见转录错误修正(与 one_video 一致,按长度降序排列避免短词误替换) CORRECTIONS = { - '私余': '私域', '统安': '同安', '信一下': '线上', '头里': '投入', - '幅画': '负责', '施育': '私域', '经历论': '净利润', '成于': '乘以', - '马的': '码的', '猜济': '拆解', '巨圣': '矩阵', '货客': '获客', - '甲为师': '(AI助手)', '小龙俠': '小龙虾', '小龍俠': '小龙虾', - '小龍蝦': '小龙虾', '龍蝦': '龙虾', '小龙虾': '深度AI', - '基因交狼': '技能包', '基因交流': '技能传授', '颗色': 'Cursor', - '蝌蚁': '科技AI', '千万': '千问', '吹': 'Claude', '豆包': 'AI工具', - '受伤命': '搜索引擎', '货客': '获客', '受上': 'Soul上', - '搜上': 'Soul上', '售上': 'Soul上', '寿上': 'Soul上', - '瘦上': 'Soul上', '亭上': 'Soul上', '这受': '这Soul', - '龙虾': '深度AI', '克劳德': 'Claude', + # AI 工具名称 ───────────────────────────────────────────────── + '小龙俠': 'AI工具', '小龍俠': 'AI工具', '小龍蝦': 'AI工具', + '龍蝦': 'AI工具', '小龙虾': 'AI工具', '龙虾': 'AI工具', + '克劳德': 'Claude', '科劳德': 'Claude', '吹': 'Claude', + '颗色': 'Cursor', '库色': 'Cursor', '可索': 'Cursor', + '蝌蚁': '科技AI', '千万': '千问', '豆包': 'AI工具', + '暴电码': '暴电码', '蝌蚪': 'Cursor', + # Soul 平台别字 ────────────────────────────────────────────── + '受上': 'Soul上', '搜上': 'Soul上', '售上': 'Soul上', + '寿上': 'Soul上', '瘦上': 'Soul上', '亭上': 'Soul上', + '这受': '这Soul', '受的': 'Soul的', '受里': 'Soul里', + '受平台': 'Soul平台', + # 私域/商业用语 ───────────────────────────────────────────── + '私余': '私域', '施育': '私域', '私育': '私域', + '统安': '同安', '信一下': '线上', '头里': '投入', + '幅画': '负责', '经历论': '净利润', '成于': '乘以', + '马的': '码的', '猜济': '拆解', '巨圣': '矩阵', + '货客': '获客', '甲为师': '(AI助手)', + '基因交狼': '技能包', '基因交流': '技能传授', + '受伤命': '搜索引擎', '附身': '副业', '附产': '副产', + # AI 工作流 / 编程词汇 ────────────────────────────────────── + 'Ski-er': '智能体', 'Skier': '智能体', 'SKI-er': '智能体', + '工作流': '工作流', '智能体': '智能体', + '蝌蛇': 'Cursor', '科色': 'Cursor', + 'Cloud': 'Claude', # 转录常把 Claude 误识别为 Cloud + # 繁体常见 ────────────────────────────────────────────────── + '麥': '麦', '頭': '头', '讓': '让', '說': '说', '開': '开', + '這': '这', '個': '个', '們': '们', '來': '来', '會': '会', + '裡': '里', '還': '还', '點': '点', '時': '时', '對': '对', + '電': '电', '體': '体', '為': '为', '們': '们', '後': '后', + '關': '关', '單': '单', '號': '号', '幹': '干', '達': '达', + '傳': '传', '統': '统', '際': '际', '應': '应', '問': '问', + '產': '产', '業': '业', '學': '学', '發': '发', '種': '种', + '從': '从', '給': '给', '認': '认', '過': '过', '當': '当', + '誰': '谁', '動': '动', '圖': '图', '報': '报', '費': '费', + '務': '务', '與': '与', '於': '于', '錢': '钱', '帳': '账', + '臺': '台', '台灣': '台湾', '臺灣': '台湾', + # 噪音符号/单字符 ──────────────────────────────────────────── + # (在 parse_srt 里过滤,这里不做) } # 各平台违禁词 → 谐音/替代词(用于字幕、封面、文件名) @@ -358,77 +386,124 @@ def _detect_clip_pts_offset(clip_path: str) -> float: # batch_clip -ss input seeking 导致实际切割比请求早 0~3 秒(关键帧对齐) # 字幕按 highlights.start_time 算相对时间,会比实际音频提前 # 加正值延迟 = 字幕往后推 = 与声音更同步 -SUBTITLE_DELAY_SEC = 0.8 # 根据实测 Soul 视频关键帧间隔约 2s,取保守值 +# 2025-03 实测:Soul派对直播视频关键帧间距 2-4 秒,补偿需约 2.0s +SUBTITLE_DELAY_SEC = 2.0 # 增大到 2.0,避免字幕超前于说话 + + +def _is_noise_line(text: str) -> bool: + """检测是否为噪声行(单字母、重复符号、ASR幻觉等)""" + if not text: + return True + stripped = text.strip() + # 单字母(L、A、B 等 ASR 幻觉) + if len(stripped) <= 2 and all(c.isalpha() or c in '…、。,' for c in stripped): + return True + # 全是相同字符 + if len(set(stripped)) == 1 and len(stripped) >= 3: + return True + # 纯 ASR 幻觉词 + NOISE_TOKENS = {'Agent', 'agent', 'L', 'B', 'A', 'OK', 'ok', + '...', '……', '嗯嗯嗯', '啊啊', '哈哈哈', + '呃呃', 'hmm', 'Hmm', 'Um', 'Uh'} + if stripped in NOISE_TOKENS: + return True + return False + + +def _improve_subtitle_text(text: str) -> str: + """字幕文字质量提升:纠错 + 上下文通畅 + 违禁词替换""" + if not text: + return text + # 繁转简 + t = _to_simplified(text.strip()) + # 错词修正(按词典长度降序,避免短词覆盖长词) + for w, c in sorted(CORRECTIONS.items(), key=lambda x: len(x[0]), reverse=True): + t = t.replace(w, c) + # 违禁词替换 + for w, c in PLATFORM_VIOLATIONS.items(): + t = t.replace(w, c) + # 清理语助词 + t = clean_filler_words(t) + # 去多余空格 + t = re.sub(r'\s+', ' ', t).strip() + # 末尾加句号让阅读更顺畅(如果没有标点的话) + END_PUNCTS = set('。!?…,') + if t and t[-1] not in END_PUNCTS and len(t) >= 6: + t += '。' + return t def parse_srt_for_clip(srt_path, start_sec, end_sec, delay_sec=None): """解析SRT,提取指定时间段的字幕。 - + 优化: - 1. 字幕延迟补偿(delay_sec):补偿 FFmpeg input seeking 关键帧偏移,让字幕与声音同步 - 2. 合并过短字幕:相邻字幕 <1.2s 且文字可拼接时自动合并,减少闪烁 - 3. 最小显示时长:每条至少显示 1.2s,避免一闪而过看不清 + 1. 字幕延迟补偿(delay_sec):补偿 FFmpeg input seeking 关键帧偏移(2s 默认) + 2. 噪声行过滤:去掉单字母 L / Agent 等 ASR 幻觉行 + 3. 文字质量提升:纠错 + 违禁词替换 + 通畅度修正 + 4. 合并过短字幕:相邻 <1.5s 时自动合并,减少闪烁 + 5. 最小显示时长:每条至少 1.5s,避免一闪而过 """ if delay_sec is None: delay_sec = SUBTITLE_DELAY_SEC with open(srt_path, 'r', encoding='utf-8') as f: content = f.read() - + pattern = r'(\d+)\n(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})\n(.*?)(?=\n\n|\Z)' matches = re.findall(pattern, content, re.DOTALL) - + def time_to_sec(t): t = t.replace(',', '.') parts = t.split(':') return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2]) - + raw_subs = [] for match in matches: sub_start = time_to_sec(match[1]) - sub_end = time_to_sec(match[2]) + sub_end = time_to_sec(match[2]) text = match[3].strip() - + + # 噪声行提前过滤 + if _is_noise_line(text): + continue + if sub_end > start_sec and sub_start < end_sec + 2: rel_start = max(0, sub_start - start_sec + delay_sec) - rel_end = sub_end - start_sec + delay_sec - - text = _to_simplified(text) - for w, c in CORRECTIONS.items(): - text = text.replace(w, c) - cleaned_text = clean_filler_words(text) - if len(cleaned_text) > 1: + rel_end = sub_end - start_sec + delay_sec + + improved = _improve_subtitle_text(text) + if improved and len(improved) > 1: raw_subs.append({ 'start': max(0, rel_start), - 'end': max(rel_start + 0.5, rel_end), - 'text': cleaned_text + 'end': max(rel_start + 0.5, rel_end), + 'text': improved, }) - - # 合并过短的连续字幕(<1.2s 且总长 <25字),让每条有足够阅读时间 - MIN_DISPLAY = 1.2 + + # 合并过短的连续字幕(<1.5s 且总长 <28字),让每条有足够阅读时间 + MIN_DISPLAY = 1.5 merged = [] i = 0 while i < len(raw_subs): cur = dict(raw_subs[i]) dur = cur['end'] - cur['start'] - # 尝试向后合并 while dur < MIN_DISPLAY and i + 1 < len(raw_subs): nxt = raw_subs[i + 1] gap = nxt['start'] - cur['end'] - combined_text = cur['text'] + ',' + nxt['text'] - if gap <= 0.5 and len(combined_text) <= 25: - cur['end'] = nxt['end'] - cur['text'] = combined_text + # 去掉句尾句号再合并 + base_text = cur['text'].rstrip('。!?,') + combined = base_text + ',' + nxt['text'] + if gap <= 0.6 and len(combined) <= 28: + cur['end'] = nxt['end'] + cur['text'] = combined dur = cur['end'] - cur['start'] i += 1 else: break - # 强制最小显示时长 if cur['end'] - cur['start'] < MIN_DISPLAY: cur['end'] = cur['start'] + MIN_DISPLAY merged.append(cur) i += 1 - + return merged @@ -485,13 +560,14 @@ def _sec_to_srt_time(sec): def write_clip_srt(srt_path, subtitles, cover_duration): """写出用于烧录的 SRT(仅保留封面结束后的字幕,时间已相对片段)""" + safe_start = cover_duration + 0.3 lines = [] idx = 1 for sub in subtitles: start, end = sub['start'], sub['end'] - if end <= cover_duration: + if end <= safe_start: continue - start = max(start, cover_duration) + start = max(start, safe_start) text = (sub.get('text') or '').strip().replace('\n', ' ') if not text: continue @@ -991,7 +1067,45 @@ def enhance_clip(clip_path, output_path, highlight_info, temp_dir, transcript_pa except (IndexError, ValueError): start_sec = 0 end_sec = start_sec + duration - subtitles = parse_srt_for_clip(transcript_path, start_sec, end_sec) + + # 动态字幕延迟:检测切片实际首帧 PTS,与请求 start_time 做差 + actual_delay = SUBTITLE_DELAY_SEC + try: + pts_cmd = [ + "ffprobe", "-v", "quiet", "-select_streams", "v:0", + "-show_entries", "frame=pts_time", + "-read_intervals", "%+0.1", + "-print_format", "csv=p=0", + str(clip_path), + ] + pts_r = subprocess.run(pts_cmd, capture_output=True, text=True, timeout=10) + if pts_r.returncode == 0 and pts_r.stdout.strip(): + first_pts = float(pts_r.stdout.strip().split("\n")[0].strip()) + # batch_clip 把 -ss 放在 -i 前面,FFmpeg 将 PTS 重置为 0 + # 但实际音频起点可能比请求的 start_sec 早 0-4 秒(关键帧对齐) + # first_pts 接近 0,真正的偏移量在 batch_clip 的 seeking 行为里 + # 更可靠的方法:检测音频首个有效帧的 PTS + audio_cmd = [ + "ffprobe", "-v", "quiet", "-select_streams", "a:0", + "-show_entries", "frame=pts_time", + "-read_intervals", "%+0.5", + "-print_format", "csv=p=0", + str(clip_path), + ] + audio_r = subprocess.run(audio_cmd, capture_output=True, text=True, timeout=10) + if audio_r.returncode == 0 and audio_r.stdout.strip(): + audio_pts = float(audio_r.stdout.strip().split("\n")[0].strip()) + # 视频帧 PTS 与音频帧 PTS 的差值揭示了 seeking 偏移 + offset = abs(first_pts - audio_pts) + # 关键帧对齐通常导致视频比音频早 0-3s + # 字幕需要额外推迟这个偏移量 + actual_delay = max(1.5, SUBTITLE_DELAY_SEC + offset * 0.5) + if actual_delay > 4.0: + actual_delay = SUBTITLE_DELAY_SEC + except Exception: + pass + + subtitles = parse_srt_for_clip(transcript_path, start_sec, end_sec, delay_sec=actual_delay) for sub in subtitles: if not _is_mostly_chinese(sub['text']): sub['text'] = _translate_to_chinese(sub['text']) or sub['text'] diff --git a/03_卡木(木)/木叶_视频内容/视频切片/脚本/visual_enhance.py b/03_卡木(木)/木叶_视频内容/视频切片/脚本/visual_enhance.py index c9318777..68576908 100644 --- a/03_卡木(木)/木叶_视频内容/视频切片/脚本/visual_enhance.py +++ b/03_卡木(木)/木叶_视频内容/视频切片/脚本/visual_enhance.py @@ -1,17 +1,14 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """ -视觉增强 v7:苹果毛玻璃风格底部浮层 -设计规范来源:卡若AI 前端标准(神射手/毛狐狸) +视觉增强 v8:苹果毛玻璃底部浮层(终版) -核心设计原则: -1. 无视频小窗 —— 彻底去掉,全宽用于内容展示 -2. 苹果毛玻璃 —— backdrop-blur + rgba深色底 + 白边 + 顶部高光条 -3. 图标体系 —— 每类内容有专属 Unicode 图标,可读性强 -4. 渐变强调 —— 蓝→紫主渐变,状态色 green/gold/red -5. 两档字体 —— medium 标题,regular 正文,不堆叠字重 -6. 大留白 —— 元素少,每个元素有呼吸空间 -7. 底部芯片 —— 渐变边框胶囊,不做满色填充 +改动: +- 去掉所有图标 badge、问号圆圈、Unicode 图标前缀、白块 +- 左上角加载卡若创业派对 Logo + "第 N 场" +- 场景按主题段落切换:开头提问 → 中间要点总结 → 结尾 CTA +- 配色与 Soul 绿协调的青绿色系 +- 芯片改为渐变描边胶囊 """ import argparse import hashlib @@ -29,848 +26,412 @@ except ImportError: sys.exit("pip3 install Pillow") SCRIPT_DIR = Path(__file__).resolve().parent -FONTS_DIR = SCRIPT_DIR.parent / "fonts" +SKILL_DIR = SCRIPT_DIR.parent +FONTS_DIR = SKILL_DIR / "fonts" if not FONTS_DIR.exists(): FONTS_DIR = Path("/Users/karuo/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/视频切片/fonts") +LOGO_PATH = SKILL_DIR / "参考资料" / "karuo_logo.png" VW, VH = 498, 1080 -PANEL_W, PANEL_H = 428, 330 +PANEL_W, PANEL_H = 428, 310 PANEL_X = (VW - PANEL_W) // 2 PANEL_Y = VH - PANEL_H - 30 FPS = 8 CURRENT_SEED = "default" -# ── 前端规范色板(来自神射手/毛狐狸)──────────────────────────────── +GLASS_TOP = (12, 15, 26, 225) +GLASS_BTM = (8, 10, 20, 235) +GLASS_BORDER = (255, 255, 255, 26) +GLASS_INNER = (255, 255, 255, 12) -# 深色毛玻璃底(模拟 bg-slate-900/88 + backdrop-blur) -GLASS_FILL_TOP = (14, 16, 28, 222) -GLASS_FILL_BTM = (10, 12, 22, 232) -GLASS_BORDER = (255, 255, 255, 30) # border-white/12 -GLASS_INNER_EDGE = (255, 255, 255, 14) # inner inset -GLASS_HIGHLIGHT = (255, 255, 255, 28) # top highlight strip +SOUL_GREEN = (0, 210, 106) +ACCENT_A = (0, 200, 140, 255) +ACCENT_B = (52, 211, 238, 255) -# 前端规范色 —— 主渐变蓝→紫 -BLUE = (96, 165, 250, 255) # blue-400 -PURPLE = (167, 139, 250, 255) # violet-400 -CYAN = (34, 211, 238, 255) # cyan-400 -GREEN = (52, 211, 153, 255) # emerald-400 -GOLD = (251, 191, 36, 255) # amber-400 -ORANGE = (251, 146, 60, 255) # orange-400 -RED = (248, 113, 113, 255) # red-400 -PINK = (244, 114, 182, 255) # pink-400 -WHITE = (248, 250, 255, 255) +TEXT_PRI = (240, 244, 255, 255) +TEXT_SEC = (163, 177, 206, 255) +TEXT_MUT = (100, 116, 145, 255) +WHITE = (248, 250, 255, 255) -TEXT_PRIMARY = (240, 244, 255, 255) # near-white, slightly blue tint -TEXT_SECONDARY = (163, 177, 206, 255) # slate-400 equivalent -TEXT_MUTED = (100, 116, 145, 255) # slate-500 - -# 渐变强调组(每条视频可用不同组) -ACCENT_PALETTES = [ - {"a": BLUE, "b": PURPLE, "name": "blueprint"}, - {"a": CYAN, "b": BLUE, "name": "oceanic"}, - {"a": GREEN, "b": CYAN, "name": "emerald"}, - {"a": GOLD, "b": ORANGE, "name": "solar"}, - {"a": PURPLE, "b": PINK, "name": "lavender"}, -] - -# ── 图标系统(Unicode,确保 CJK 字体支持)─────────────────────────── -ICONS = { - "question": "?", # question mark – bold rendition - "data": "◆", - "flow": "▸", - "compare": "⇌", - "mind": "◎", - "summary": "✦", - "check": "✓", - "cross": "✕", - "bullet": "·", - "arrow_right": "→", - "star": "★", - "spark": "✦", - "tag": "⊕", - "globe": "⊙", - "target": "◎", - # 数字圈 - "n1": "①", "n2": "②", "n3": "③", "n4": "④", "n5": "⑤", - "n6": "⑥", "n7": "⑦", "n8": "⑧", - # 类别前缀 - "ai": "A", - "money": "¥", - "time": "⏱", - "chart": "≋", -} - -NUMBERED = ["①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧"] +_logo_cache = None -# ── 字体加载 ───────────────────────────────────────────────────────── +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 -def font(size: int, weight: str = "medium"): + +def font(size, weight="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"), - ], + "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")], } - for path in mapping.get(weight, mapping["medium"]): - if path.exists(): + for p in mapping.get(weight, mapping["medium"]): + if p.exists(): try: - return ImageFont.truetype(str(path), size) + return ImageFont.truetype(str(p), size) except Exception: continue return ImageFont.load_default() -# ── 工具函数 ────────────────────────────────────────────────────────── - -def ease_out(t: float) -> float: +def ease_out(t): t = max(0.0, min(1.0, t)) return 1 - (1 - t) ** 3 -def ease_in_out(t: float) -> float: - t = max(0.0, min(1.0, t)) - return 3 * t * t - 2 * t * t * t - - -def blend_color(c1, c2, t: float): +def blend(c1, c2, t): return tuple(int(a + (b - a) * max(0, min(1, t))) for a, b in zip(c1, c2)) -def hash_seed(text: str) -> int: - return int(hashlib.md5(text.encode()).hexdigest()[:8], 16) - - -def get_palette(idx: int) -> dict: - seed = hash_seed(f"{CURRENT_SEED}|{idx}") - return ACCENT_PALETTES[seed % len(ACCENT_PALETTES)] - - -def text_bbox(fnt, text: str): - return fnt.getbbox(text) - - -def text_width(fnt, text: str) -> int: - bb = text_bbox(fnt, text) +def tw(f, t): + bb = f.getbbox(t) return bb[2] - bb[0] -def text_height(fnt, text: str) -> int: - bb = text_bbox(fnt, text) +def th(f, t): + bb = f.getbbox(t) return bb[3] - bb[1] -def draw_center(draw, text: str, fnt, y: int, fill, canvas_w: int = PANEL_W): - tw = text_width(fnt, text) - draw.text(((canvas_w - tw) // 2, y), text, font=fnt, fill=fill) +def draw_center(d, text, f, y, fill, cw=PANEL_W): + d.text(((cw - tw(f, text)) // 2, y), text, font=f, fill=fill) -def draw_wrapped(draw, text: str, fnt, max_w: int, x: int, y: int, fill, gap: int = 6) -> int: +def draw_wrap(d, text, f, max_w, x, y, fill, gap=6): lines, cur = [], "" for ch in text: - test = cur + ch - if text_width(fnt, test) > max_w and cur: - lines.append(cur) - cur = ch + t = cur + ch + if tw(f, t) > max_w and cur: + lines.append(cur); cur = ch else: - cur = test + cur = t if cur: lines.append(cur) for line in lines: - draw.text((x, y), line, font=fnt, fill=fill) - y += text_height(fnt, line) + gap + d.text((x, y), line, font=f, fill=fill) + y += th(f, line) + gap return y -def draw_wrapped_center(draw, text: str, fnt, max_w: int, y: int, fill, canvas_w: int = PANEL_W, gap: int = 6) -> int: +def draw_wrap_center(d, text, f, max_w, y, fill, cw=PANEL_W, gap=6): lines, cur = [], "" for ch in text: - test = cur + ch - if text_width(fnt, test) > max_w and cur: - lines.append(cur) - cur = ch + t = cur + ch + if tw(f, t) > max_w and cur: + lines.append(cur); cur = ch else: - cur = test + cur = t if cur: lines.append(cur) for line in lines: - draw_center(draw, line, fnt, y, fill, canvas_w) - y += text_height(fnt, line) + gap + draw_center(d, line, f, y, fill, cw) + y += th(f, line) + gap return y -# ── 面板底座 ───────────────────────────────────────────────────────── - -def _make_shadow(blur: int = 22, alpha: int = 145): +def _shadow(): img = Image.new("RGBA", (PANEL_W, PANEL_H), (0, 0, 0, 0)) d = ImageDraw.Draw(img) - d.rounded_rectangle((16, 22, PANEL_W - 18, PANEL_H - 4), radius=28, fill=(0, 0, 0, alpha)) - d.rounded_rectangle((32, 40, PANEL_W - 36, PANEL_H - 18), radius=24, - fill=(8, 14, 44, int(alpha * 0.22))) - return img.filter(ImageFilter.GaussianBlur(blur)) + d.rounded_rectangle((14, 20, PANEL_W - 16, PANEL_H - 4), radius=28, fill=(0, 0, 0, 150)) + return img.filter(ImageFilter.GaussianBlur(22)) -def _make_glass_panel(): - """苹果毛玻璃面板:深色渐变 + 顶部高光 + 双层边框""" +def _glass(): 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_color(GLASS_FILL_TOP, GLASS_FILL_BTM, t)) + gd.line([(0, y), (PANEL_W, y)], fill=blend(GLASS_TOP, GLASS_BTM, t)) mask = Image.new("L", (PANEL_W, PANEL_H), 0) - ImageDraw.Draw(mask).rounded_rectangle((0, 0, PANEL_W - 1, PANEL_H - 1), radius=28, fill=255) + ImageDraw.Draw(mask).rounded_rectangle((0, 0, PANEL_W - 1, PANEL_H - 1), radius=26, fill=255) img = Image.composite(grad, img, mask) d = ImageDraw.Draw(img) - # 外边框 - d.rounded_rectangle((0, 0, PANEL_W - 1, PANEL_H - 1), radius=28, - outline=GLASS_BORDER, width=1) - # 内描边(营造厚度感) - d.rounded_rectangle((3, 3, PANEL_W - 4, PANEL_H - 4), radius=26, - outline=GLASS_INNER_EDGE, width=1) - # 顶部高光(模拟光泽) - for y in range(28): - alpha = int(GLASS_HIGHLIGHT[3] * (1 - y / 28) * 0.9) - d.line([(22, y + 4), (PANEL_W - 22, y + 4)], fill=(255, 255, 255, alpha)) + 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)) return img -# ── 图标方块(inspired by lucide-react 功能入口卡片)──────────────── - -def _icon_badge(icon: str, color_a, color_b, size: int = 34): - """渐变圆角图标方块,类似 from-blue-500 to-purple-500""" - img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - d = ImageDraw.Draw(img) - # 渐变背景 - for x in range(size): - t = x / max(size - 1, 1) - col = blend_color(color_a, color_b, t)[:3] + (230,) - d.line([(x, 0), (x, size)], fill=col) - mask = Image.new("L", (size, size), 0) - ImageDraw.Draw(mask).rounded_rectangle((0, 0, size - 1, size - 1), radius=10, fill=255) - img = Image.composite(img, Image.new("RGBA", (size, size), (0, 0, 0, 0)), mask) - d2 = ImageDraw.Draw(img) - ff = font(size - 12, "semibold") - tw = text_width(ff, icon) - th = text_height(ff, icon) - d2.text(((size - tw) // 2, (size - th) // 2 - 2), icon, font=ff, fill=WHITE) - return img - - -# ── 底部芯片(glassmorphism button 风格)──────────────────────────── - -def _chip(text: str, palette: dict, active: float = 1.0): - """渐变描边胶囊,模拟 glass-button 样式""" - pad_x = 14 - ff = font(13, "medium") - tw = text_width(ff, text) - w = max(70, tw + pad_x * 2) - h = 32 +def _chip(text, active=1.0): + pad = 12 + ff = font(12, "medium") + w = max(60, tw(ff, text) + pad * 2) + h = 28 img = Image.new("RGBA", (w, h), (0, 0, 0, 0)) d = ImageDraw.Draw(img) - # 底色(轻度填充) - d.rounded_rectangle((0, 0, w - 1, h - 1), radius=16, - fill=(255, 255, 255, int(14 * active))) - # 渐变描边模拟:先画一个大圆角矩形再稍小覆盖 - for i, col in enumerate([palette["a"], palette["b"]]): - x_pct = i / 1.0 - bc = blend_color(palette["a"], palette["b"], x_pct)[:3] + (int(160 * active),) - # 用 a→b 在 x 轴渐变描边 - for x in range(w): - t = x / max(w - 1, 1) - col = blend_color(palette["a"], palette["b"], t)[:3] + (int(150 * active),) - if x == 0 or x == w - 1: - d.line([(x, 8), (x, h - 8)], fill=col, width=1) - d.rounded_rectangle((0, 0, w - 1, h - 1), radius=16, - outline=blend_color(palette["a"], palette["b"], 0.5)[:3] + (int(150 * active),), - width=1) - # 文字 - th = text_height(ff, text) - d.text((pad_x, (h - th) // 2 - 1), text, font=ff, - fill=(TEXT_PRIMARY[0], TEXT_PRIMARY[1], TEXT_PRIMARY[2], int(255 * active))) + 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))) return img -def _place_chips(base: Image.Image, chips: list, palette: dict, anim_t: float): - """底部芯片行,从左到右居中排列""" - gap = 10 - images = [_chip(c, palette) for c in chips] - total_w = sum(im.width for im in images) + gap * (len(images) - 1) - x = (PANEL_W - total_w) // 2 - y = PANEL_H - 44 - for i, (im, _) in enumerate(zip(images, chips)): - delay = i * 0.12 - t = ease_out(max(0, min(1, (anim_t - 0.7 - delay) / 0.4))) - if t > 0: - offset = int((1 - t) * 10) - base.alpha_composite(im, (x, y + offset)) +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))) x += im.width + gap -# ── 头部状态行 ─────────────────────────────────────────────────────── - -def _header_bar(base: Image.Image, label: str, subtitle: str, palette: dict, t: float): +def _header(base, episode, sub_label, t): d = ImageDraw.Draw(base) - # 左侧图标 badge - badge = _icon_badge(ICONS["spark"], palette["a"], palette["b"], size=32) - badge_alpha = ease_out(min(1.0, t * 4)) - if badge_alpha > 0.05: - bx, by = 20, 20 - base.alpha_composite(badge, (bx, by)) + 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 - # 标签文字 - fl = font(12, "medium") - label_a = ease_out(min(1.0, max(0, (t - 0.1) / 0.5))) - if label_a > 0: - d.text((60, 22), label, font=fl, fill=(TEXT_PRIMARY[0], TEXT_PRIMARY[1], TEXT_PRIMARY[2], int(210 * label_a))) + 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))) - # 副标题 - fs = font(11, "regular") - sub_a = ease_out(min(1.0, max(0, (t - 0.2) / 0.5))) - if sub_a > 0 and subtitle: - d.text((60, 38), subtitle, font=fs, - fill=(TEXT_SECONDARY[0], TEXT_SECONDARY[1], TEXT_SECONDARY[2], int(180 * sub_a))) + 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 = 4 + int(1.5 * math.sin(t * 3)) - dot_color = palette["a"][:3] + (200,) - d.ellipse((PANEL_W - 30 - dot_r, 26 - dot_r, - PANEL_W - 30 + dot_r, 26 + dot_r), fill=dot_color) - # 发光圈 - glow_r = dot_r + 4 - d.ellipse((PANEL_W - 30 - glow_r, 26 - glow_r, - PANEL_W - 30 + glow_r, 26 + glow_r), - outline=dot_color[:3] + (60,), width=1) + 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 _section_title(draw, title: str, icon: str, palette: dict, y: int, t: float) -> int: - ta = ease_out(min(1.0, max(0, (t - 0.1) / 0.5))) - if ta <= 0: - return y - ff = font(14, "medium") - fi = font(14, "semibold") - # 图标(渐变色) - draw.text((20, y), icon, font=fi, fill=blend_color(palette["a"], palette["b"], 0.4)[:3] + (int(255 * ta),)) - draw.text((42, y), title, font=ff, fill=(TEXT_PRIMARY[0], TEXT_PRIMARY[1], TEXT_PRIMARY[2], int(240 * ta))) - return y + text_height(ff, title) + 10 - - -def _render_title_card(base, params, t, palette): +def _render_title(base, params, t): d = ImageDraw.Draw(base) - question = params.get("question", "") - subtitle = params.get("subtitle", "") - - y = 80 - # 问号图标 - fi = font(32, "bold") - badge = _icon_badge("?", palette["a"], palette["b"], size=40) - base.alpha_composite(badge, (20, y - 4)) - - # 主问句(打字机效果) - ff = font(22, "medium") - type_t = ease_out(min(1.0, t / 2.0)) - chars = max(1, int(len(question) * type_t)) - shown = question[:chars] + 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] cursor = "▍" if type_t < 0.95 else "" - draw_wrapped(d, shown + cursor, ff, PANEL_W - 80, 72, y, TEXT_PRIMARY) + draw_wrap(d, shown + cursor, ff, PANEL_W - 40, 20, 62, TEXT_PRI) - # 副标题 - if subtitle and t > 1.2: - sub_a = ease_out(min(1.0, (t - 1.2) / 0.7)) + if sub and t > 1.0: + sa = ease_out(min(1.0, (t - 1.0) / 0.7)) fs = font(13, "regular") - y_sub = y + 56 - d.text((70, y_sub), subtitle, font=fs, - fill=(TEXT_SECONDARY[0], TEXT_SECONDARY[1], TEXT_SECONDARY[2], int(200 * sub_a))) + d.text((20, 120), sub, font=fs, fill=(TEXT_SEC[0], TEXT_SEC[1], TEXT_SEC[2], int(200 * sa))) - # 芯片 - chips = params.get("chips", ["AI 工具", "副业赛道", "立即可做"]) - _place_chips(base, chips, palette, t) + _place_chips(base, params.get("chips", []), t) -def _render_data_card(base, params, t, palette): - d = ImageDraw.Draw(base) - items = params.get("items", []) - section_t = ease_out(min(1.0, t * 3)) - y = _section_title(d, params.get("title", ""), ICONS["data"], palette, 78, t) - - col_w = (PANEL_W - 30) // 2 - for i, item in enumerate(items[:4]): - delay = 0.2 + i * 0.12 - card_t = ease_out(min(1.0, max(0, (t - delay) / 0.55))) - if card_t <= 0: - continue - row, col = i // 2, i % 2 - cx = 16 + col * (col_w + 8) - cy = y + row * 78 - int((1 - card_t) * 12) - - # 卡片背景(轻度毛玻璃) - d.rounded_rectangle((cx, cy, cx + col_w, cy + 68), radius=14, - fill=(255, 255, 255, int(12 * card_t))) - d.rounded_rectangle((cx, cy, cx + col_w, cy + 68), radius=14, - outline=blend_color(palette["a"], palette["b"], i / 3)[:3] + (int(100 * card_t),), - width=1) - # 侧色带 - ac = blend_color(palette["a"], palette["b"], i / 3) - d.rounded_rectangle((cx, cy, cx + 3, cy + 68), radius=2, fill=ac[:3] + (int(220 * card_t),)) - - # 数值(动态增长) - raw = str(item.get("number", "")) - value = raw - try: - if "~" in raw: - lo, hi = raw.split("~") - value = f"{int(int(lo) * card_t)}~{int(int(hi) * card_t)}" - elif raw.endswith("万+"): - num = int(raw[:-2]) - value = f"{max(1, int(num * card_t))}万+" - elif raw.endswith("分钟"): - num = int(raw[:-2]) - value = f"{max(1, int(num * card_t))}分" - except Exception: - pass - fv = font(20, "medium") - fc = ac[:3] + (int(255 * card_t),) - d.text((cx + 10, cy + 6), value, font=fv, fill=fc) - fl = font(11, "regular") - d.text((cx + 10, cy + 32), item.get("label", ""), font=fl, - fill=(TEXT_SECONDARY[0], TEXT_SECONDARY[1], TEXT_SECONDARY[2], int(200 * card_t))) - fd = font(10, "regular") - d.text((cx + 10, cy + 50), item.get("desc", ""), font=fd, - fill=(TEXT_MUTED[0], TEXT_MUTED[1], TEXT_MUTED[2], int(170 * card_t))) - - _place_chips(base, params.get("chips", []), palette, t) - - -def _render_flow_chart(base, params, t, palette): - d = ImageDraw.Draw(base) - steps = params.get("steps", []) - y = _section_title(d, params.get("title", ""), ICONS["flow"], palette, 78, t) - step_h = min(40, (PANEL_H - y - 54) // max(len(steps), 1)) - - for i, step in enumerate(steps): - delay = 0.15 + i * 0.14 - st = ease_out(min(1.0, max(0, (t - delay) / 0.5))) - if st <= 0: - continue - sy = y + i * step_h - - # 圆点(渐入弹出) - cx_dot = 32 - ac = blend_color(palette["a"], palette["b"], i / max(len(steps) - 1, 1)) - dot_r = int(12 * ease_out(min(1.0, (t - delay) / 0.3))) - if dot_r > 2: - d.ellipse((cx_dot - dot_r, sy + step_h // 2 - dot_r, - cx_dot + dot_r, sy + step_h // 2 + dot_r), - fill=ac[:3] + (220,)) - # 数字 - ni = NUMBERED[i] if i < len(NUMBERED) else str(i + 1) - fn = font(11, "semibold") - nw = text_width(fn, ni) - d.text((cx_dot - nw // 2 - 1, sy + step_h // 2 - 8), ni, font=fn, - fill=(255, 255, 255, int(255 * st))) - - # 步骤文字(右滑入) - slide_x = int((1 - st) * 20) - fs = font(14, "regular") - d.text((56 + slide_x, sy + step_h // 2 - 9), step, font=fs, - fill=(TEXT_PRIMARY[0], TEXT_PRIMARY[1], TEXT_PRIMARY[2], int(250 * st))) - - # 虚线连接 - if i < len(steps) - 1: - for dy in range(step_h // 2 + 10, step_h - 2, 5): - dc = ac[:3] + (int(55 * st),) - d.ellipse((cx_dot - 1, sy + dy, cx_dot + 1, sy + dy + 2), fill=dc) - - _place_chips(base, params.get("chips", []), palette, t) - - -def _render_comparison(base, params, t, palette): - d = ImageDraw.Draw(base) - y = _section_title(d, params.get("title", ""), ICONS["compare"], palette, 78, t) - - mid = PANEL_W // 2 - mx = 16 - gap = 8 - - # 左侧框 - la = ease_out(min(1.0, max(0, (t - 0.1) / 0.6))) - if la > 0: - d.rounded_rectangle((mx, y, mid - gap, PANEL_H - 54), radius=16, - fill=(248, 113, 113, int(14 * la)), - outline=(248, 113, 113, int(80 * la)), width=1) - # 右侧框 - ra = ease_out(min(1.0, max(0, (t - 0.2) / 0.6))) - if ra > 0: - d.rounded_rectangle((mid + gap, y, PANEL_W - mx, PANEL_H - 54), radius=16, - fill=(52, 211, 153, int(14 * ra)), - outline=(52, 211, 153, int(80 * ra)), width=1) - - # 标题行 - fh = font(13, "medium") - if la > 0.2: - d.text((mx + 12, y + 10), ICONS["cross"] + " " + params.get("left_title", ""), font=fh, - fill=(248, 113, 113, int(220 * la))) - if ra > 0.2: - d.text((mid + gap + 12, y + 10), ICONS["check"] + " " + params.get("right_title", ""), font=fh, - fill=(52, 211, 153, int(220 * ra))) - - fl = font(13, "regular") - iy = y + 36 - for j, item in enumerate(params.get("left_items", [])): - ia = ease_out(min(1.0, max(0, (t - 0.3 - j * 0.1) / 0.45))) - if ia > 0: - d.text((mx + 10 - int((1 - ia) * 12), iy + j * 28), item, font=fl, - fill=(248, 113, 113, int(200 * ia))) - iy = y + 36 - for j, item in enumerate(params.get("right_items", [])): - ia = ease_out(min(1.0, max(0, (t - 0.5 - j * 0.1) / 0.45))) - if ia > 0: - d.text((mid + gap + 10 + int((1 - ia) * 12), iy + j * 28), item, font=fl, - fill=(52, 211, 153, int(200 * ia))) - - _place_chips(base, params.get("chips", []), palette, t) - - -def _render_mindmap(base, params, t, palette): - d = ImageDraw.Draw(base) - center = params.get("center", "核心") - branches = params.get("branches", []) - cx, cy = PANEL_W // 2, (PANEL_H - 54) // 2 + 40 - - # 中心节点(缩放入场) - ct = ease_out(min(1.0, t / 0.7)) - cr = int(36 * ct) + int(3 * math.sin(t * 2)) - if cr > 3: - # 渐变圆背景 - for r in range(cr, 0, -2): - tf = r / cr - col = blend_color(palette["a"], palette["b"], 1 - tf)[:3] + (int(200 * tf * ct),) - d.ellipse((cx - r, cy - r, cx + r, cy + r), fill=col) - fc = font(13, "semibold") - cw = text_width(fc, center) - d.text((cx - cw // 2, cy - 9), center, font=fc, fill=WHITE) - - # 分支 - n = len(branches) - for i, br in enumerate(branches): - bt = ease_out(min(1.0, max(0, (t - 0.3 - i * 0.1) / 0.55))) - if bt <= 0: - continue - ang = math.radians(-90 + i * (360 / n)) - dist = 90 * bt - bx = cx + int(math.cos(ang) * dist) - by = cy + int(math.sin(ang) * dist) - ac = blend_color(palette["a"], palette["b"], i / max(n - 1, 1)) - - # 连线 - d.line([(cx, cy), (bx, by)], fill=ac[:3] + (int(100 * bt),), width=1) - - # 分支节点背景 - fb = font(11, "medium") - bw = text_width(fb, br) + 16 - bh = 22 - d.rounded_rectangle((bx - bw // 2, by - bh // 2, bx + bw // 2, by + bh // 2), radius=11, - fill=ac[:3] + (int(35 * bt),), outline=ac[:3] + (int(120 * bt),), width=1) - d.text((bx - bw // 2 + 8, by - bh // 2 + 4), br, font=fb, fill=ac[:3] + (int(245 * bt),)) - - _place_chips(base, params.get("chips", []), palette, t) - - -def _render_summary(base, params, t, palette): +def _render_summary(base, params, t): d = ImageDraw.Draw(base) headline = params.get("headline", "") points = params.get("points", []) cta = params.get("cta", "") - # 顶部标题框 - ha = ease_out(min(1.0, max(0, (t - 0.05) / 0.55))) + ha = ease_out(min(1.0, max(0, t / 0.5))) if ha > 0: - d.rounded_rectangle((18, 74, PANEL_W - 18, 116), radius=20, - fill=blend_color(palette["a"], palette["b"], 0.5)[:3] + (int(25 * ha),), - outline=blend_color(palette["a"], palette["b"], 0.5)[:3] + (int(100 * ha),), - width=1) - fh = font(20, "medium") - draw_wrapped_center(d, headline, fh, PANEL_W - 60, 82, TEXT_PRIMARY) + 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 = 128 + y = 110 for i, pt in enumerate(points): - ia = ease_out(min(1.0, max(0, (t - 0.3 - i * 0.12) / 0.5))) + ia = ease_out(min(1.0, max(0, (t - 0.25 - i * 0.1) / 0.45))) if ia <= 0: continue - ac = blend_color(palette["a"], palette["b"], i / max(len(points) - 1, 1)) - # 颜色点 - d.ellipse((22, y + i * 32 + 5, 30, y + i * 32 + 13), - fill=ac[:3] + (int(240 * ia),)) - fp = font(14, "regular") - d.text((38 + int((1 - ia) * 14), y + i * 32), pt, font=fp, - fill=(TEXT_PRIMARY[0], TEXT_PRIMARY[1], TEXT_PRIMARY[2], int(250 * ia))) + 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))) - # CTA 按钮(渐变填充) if cta: - ca = ease_out(min(1.0, max(0, (t - 1.0) / 0.45))) + ca = ease_out(min(1.0, max(0, (t - 0.9) / 0.4))) if ca > 0: - by = PANEL_H - 54 - int((1 - ca) * 8) - for x in range(44, PANEL_W - 44): - tf = (x - 44) / max(PANEL_W - 88, 1) - col = blend_color(palette["a"], palette["b"], tf)[:3] + (int(210 * ca),) - d.line([(x, by), (x, by + 28)], fill=col) - d.rounded_rectangle((44, by, PANEL_W - 44, by + 28), radius=14, - outline=(255, 255, 255, int(40 * ca)), width=1) - fc = font(13, "medium") - draw_center(d, cta, fc, by + 6, WHITE) + 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) -# ── 场景渲染调度 ────────────────────────────────────────────────────── - RENDERERS = { - "title_card": _render_title_card, - "data_card": _render_data_card, - "flow_chart": _render_flow_chart, - "comparison_card": _render_comparison, - "mindmap_card": _render_mindmap, - "summary_card": _render_summary, + "title_card": _render_title, + "summary_card": _render_summary, } -def compose_frame(scene: dict, local_t: float, palette: dict) -> Image.Image: - scene_type = scene.get("type", "title_card") +def compose_frame(scene, local_t): params = scene.get("params", {}) - scene_progress = (local_t % 5.0) / 5.0 + episode = scene.get("episode", "") + sub_label = scene.get("sub_label", "") + scene_type = scene.get("type", "title_card") base = Image.new("RGBA", (PANEL_W, PANEL_H), (0, 0, 0, 0)) - base.alpha_composite(_make_shadow(), (0, 0)) - base.alpha_composite(_make_glass_panel(), (0, 0)) - - renderer = RENDERERS.get(scene_type) - if renderer: - renderer(base, params, local_t, palette) - - # 头部标签 - label = scene.get("label", "卡若 · 精华") - sub_label = scene.get("sub_label", "") - _header_bar(base, label, sub_label, palette, local_t) - + 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) return base -def render_overlay_frame(scene: dict, local_t: float, scene_idx: int) -> Image.Image: - palette = get_palette(scene_idx) - panel = compose_frame(scene, local_t, palette) - - # 整体漂浮 + 呼吸缩放 - intro = ease_out(min(1.0, local_t / 0.6)) - breath = 1 + math.sin(local_t * 1.3) * 0.011 +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 scale = (0.94 + intro * 0.06) * breath - y_drift = int((1 - intro) * 18 + math.sin(local_t * 1.0) * 4) - x_drift = int(math.sin(local_t * 0.65) * 2) - - panel_s = panel.resize((int(PANEL_W * scale), int(PANEL_H * scale)), Image.LANCZOS) + 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) frame = Image.new("RGBA", (VW, VH), (0, 0, 0, 0)) - px = (VW - panel_s.width) // 2 + x_drift - py = PANEL_Y - (panel_s.height - PANEL_H) // 2 + y_drift - frame.alpha_composite(panel_s, (max(0, px), max(0, py))) + 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))) return frame -# ── 默认场景(用于测试)────────────────────────────────────────────── - DEFAULT_SCENES = [ - { - "start": 0, "end": 30, - "type": "title_card", - "label": "卡若 · 精华", - "sub_label": "AI 工具真实评测", - "params": { - "question": "哪个AI模型才是真正意义上的AI?", - "subtitle": "深度AI模型对比:不是语言模型", - "chips": ["深度AI", "语言模型", "真实评测"], - }, - }, - { - "start": 30, "end": 90, - "type": "comparison_card", - "label": "卡若 · 对比", - "sub_label": "工具性能差异分析", - "params": { - "title": "语言模型 vs 真正的AI", - "left_title": "语言模型", - "left_items": ["只回答文字", "无法执行动作", "不学习记忆"], - "right_title": "深度AI", - "right_items": ["理解并执行", "动态调整策略", "持续学习反馈"], - "chips": ["能力差异", "使用场景", "选型建议"], - }, - }, - { - "start": 90, "end": 150, - "type": "flow_chart", - "label": "卡若 · 方法论", - "sub_label": "评测流程", - "params": { - "title": "怎么判断一个AI是否真正有用", - "steps": ["提一个具体任务", "看它会不会主动拆解", "看执行后有没有反馈", "反复迭代才是真AI"], - "chips": ["判断标准", "实操方法", "避坑指南"], - }, - }, - { - "start": 150, "end": 190, - "type": "summary_card", - "label": "卡若 · 总结", - "sub_label": "你可以直接用", - "params": { - "headline": "选AI就选能执行的那个", - "points": ["语言模型≠真正的AI", "执行力是核心判断标准", "先用深度AI跑一遍再说"], - "cta": "关注 · 了解更多AI工具", - }, - }, + {"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": "关注了解更多"}}, ] -# ── 渲染引擎 ───────────────────────────────────────────────────────── - -def render_scene_clip(scene: dict, scene_idx: int, tmp_dir: str) -> dict | None: +def render_scene_clip(scene, idx, tmp): dur = float(scene["end"] - scene["start"]) - sdir = os.path.join(tmp_dir, f"sc_{scene_idx:03d}") + sdir = os.path.join(tmp, f"s{idx:02d}") os.makedirs(sdir, exist_ok=True) - n_frames = max(1, int(dur * FPS)) + nf = max(1, int(dur * FPS)) concat = [] - last_fp = None - tp = scene.get("type", "?") - pal_name = get_palette(scene_idx)["name"] - print(f" [{scene_idx+1}] {tp} {scene['start']:.0f}s–{scene['end']:.0f}s ({n_frames}f, {pal_name})...", end="", flush=True) - for i in range(n_frames): + 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): lt = i / FPS - frame = render_overlay_frame(scene, lt, scene_idx) + frame = render_overlay(scene, lt) fp = os.path.join(sdir, f"f{i:04d}.png") frame.save(fp, "PNG") concat.append(f"file '{fp}'") - concat.append(f"duration {1.0/FPS:.4f}") - last_fp = fp - concat.append(f"file '{last_fp}'") - cf = os.path.join(sdir, "concat.txt") + concat.append(f"duration {1.0 / FPS:.4f}") + last = fp + concat.append(f"file '{last}'") + cf = os.path.join(sdir, "c.txt") with open(cf, "w") as f: f.write("\n".join(concat)) - mov = os.path.join(sdir, "sc.mov") - cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", cf, - "-vf", "fps=25,format=rgba", "-c:v", "png", "-t", f"{dur:.3f}", mov] - r = subprocess.run(cmd, capture_output=True, text=True) + 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) if r.returncode != 0: - print(f" ERR", flush=True) - return None - print(" ✓", flush=True) + print(" ERR", flush=True); return None + print(" OK", flush=True) return {"path": mov, "start": scene["start"], "end": scene["end"]} -def build_overlay_stream(clips: list, duration: float, tmp_dir: str) -> str | None: +def build_overlay(clips, duration, tmp): blank = Image.new("RGBA", (VW, VH), (0, 0, 0, 0)) - bp = os.path.join(tmp_dir, "blank.png") + bp = os.path.join(tmp, "b.png") blank.save(bp, "PNG") concat = [] prev = 0.0 for c in clips: if c["start"] > prev + 0.05: - concat += [f"file '{bp}'", f"duration {c['start']-prev:.3f}"] + concat += [f"file '{bp}'", f"duration {c['start'] - prev:.3f}"] concat.append(f"file '{c['path']}'") prev = c["end"] if prev < duration: - concat += [f"file '{bp}'", f"duration {duration-prev:.3f}"] + concat += [f"file '{bp}'", f"duration {duration - prev:.3f}"] concat.append(f"file '{bp}'") - cf = os.path.join(tmp_dir, "ov_concat.txt") + cf = os.path.join(tmp, "oc.txt") with open(cf, "w") as f: f.write("\n".join(concat)) - out = os.path.join(tmp_dir, "overlay.mov") - cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", cf, - "-vf", "fps=25,format=rgba", "-c:v", "png", "-t", f"{duration:.3f}", out] - print(" 合并叠加流...", end="", flush=True) - r = subprocess.run(cmd, capture_output=True, text=True) + 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) if r.returncode != 0: - print(f" ERR", flush=True) - return None - mb = os.path.getsize(out) / 1024 / 1024 - print(f" ✓ ({mb:.0f}MB)", flush=True) + print(" ERR", flush=True); return None + mb = os.path.getsize(out) // 1024 // 1024 + print(f" OK ({mb}MB)", flush=True) return out -def compose_final(input_video: str, overlay: str, output: str, duration: float) -> bool: - cmd = [ - "ffmpeg", "-y", "-i", input_video, "-i", overlay, - "-filter_complex", "[1:v]format=rgba[ov];[0:v][ov]overlay=0:0:format=auto:shortest=1[v]", +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]", "-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, - ] - print(" 最终合成...", end="", flush=True) - r = subprocess.run(cmd, capture_output=True, text=True) + "-c:a", "aac", "-b:a", "128k", "-t", f"{dur:.3f}", "-movflags", "+faststart", outp, + ], capture_output=True, text=True) if r.returncode != 0: - print(f" ERR", flush=True) return False - mb = os.path.getsize(output) / 1024 / 1024 - print(f" ✓ ({mb:.1f}MB)", flush=True) + mb = os.path.getsize(outp) // 1024 // 1024 + print(f" 合成 OK ({mb}MB)", flush=True) return True -def get_dur(v: str) -> float: - r = subprocess.run(["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", v], - capture_output=True, text=True) +def get_dur(v): + r = subprocess.run(["ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", v], capture_output=True, text=True) return float(json.loads(r.stdout)["format"]["duration"]) def main(): global CURRENT_SEED - ap = argparse.ArgumentParser(description="视觉增强 v7 苹果毛玻璃浮层") + ap = argparse.ArgumentParser(description="视觉增强 v8") 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 - 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_dur(args.input) - for sc in scenes: - sc["end"] = min(sc["end"], duration) - + dur = get_dur(args.input) + for s in scenes: + s["end"] = min(s["end"], dur) os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) - print(f"输入: {Path(args.input).name} ({duration:.0f}s)") - print(f"场景: {len(scenes)} 段 · 苹果毛玻璃 v7\n") - - with tempfile.TemporaryDirectory(prefix="ve7_") as tmp: - print("【1/3】生成动态帧...", flush=True) - clips = [c for c in (render_scene_clip(sc, i, tmp) for i, sc in enumerate(scenes)) if c] + 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] if not clips: sys.exit(1) - print(f"\n【2/3】构建叠加流 ({len(clips)} 段)...", flush=True) - ov = build_overlay_stream(clips, duration, tmp) + print(f"\n【2/3】叠加流 ({len(clips)} 段)...", flush=True) + ov = build_overlay(clips, dur, tmp) if not ov: sys.exit(1) - print("\n【3/3】合成成片...", flush=True) - if not compose_final(args.input, ov, args.output, duration): + print("\n【3/3】合成...", flush=True) + if not compose_final(args.input, ov, args.output, dur): sys.exit(1) - print(f"\n✅ 完成: {args.output}") + print(f"\n完成: {args.output}") if __name__ == "__main__": diff --git a/运营中枢/工作台/gitea_push_log.md b/运营中枢/工作台/gitea_push_log.md index b44141c2..1dc4a443 100644 --- a/运营中枢/工作台/gitea_push_log.md +++ b/运营中枢/工作台/gitea_push_log.md @@ -325,3 +325,4 @@ | 2026-03-13 11:05:21 | 🔄 卡若AI 同步 2026-03-13 11:05 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | | 2026-03-13 11:10:45 | 🔄 卡若AI 同步 2026-03-13 11:10 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | | 2026-03-13 11:14:47 | 🔄 卡若AI 同步 2026-03-13 11:14 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | +| 2026-03-13 11:49:08 | 🔄 卡若AI 同步 2026-03-13 11:49 | 更新:卡土、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 | diff --git a/运营中枢/工作台/代码管理.md b/运营中枢/工作台/代码管理.md index 135087c5..955c0cbb 100644 --- a/运营中枢/工作台/代码管理.md +++ b/运营中枢/工作台/代码管理.md @@ -328,3 +328,4 @@ | 2026-03-13 11:05:21 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-13 11:05 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | | 2026-03-13 11:10:45 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-13 11:10 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | | 2026-03-13 11:14:47 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-13 11:14 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | +| 2026-03-13 11:49:08 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-13 11:49 | 更新:卡土、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |