From 03ddd1706d9af39086894917193158dae8b6d8be Mon Sep 17 00:00:00 2001 From: karuo Date: Fri, 13 Mar 2026 13:50:26 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=84=20=E5=8D=A1=E8=8B=A5AI=20=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=202026-03-13=2013:50=20|=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=EF=BC=9A=E5=8D=A1=E6=9C=A8=E3=80=81=E8=BF=90=E8=90=A5=E4=B8=AD?= =?UTF-8?q?=E6=9E=A2=E5=B7=A5=E4=BD=9C=E5=8F=B0=20|=20=E6=8E=92=E9=99=A4?= =?UTF-8?q?=20>20MB:=2011=20=E4=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../木叶_视频内容/视频切片/Soul竖屏切片_SKILL.md | 42 +- .../木叶_视频内容/视频切片/参考资料/karuo_logo.png | Bin 0 -> 36199 bytes .../木叶_视频内容/视频切片/脚本/soul_enhance.py | 202 +++- .../木叶_视频内容/视频切片/脚本/visual_enhance.py | 901 +++++------------- 运营中枢/工作台/gitea_push_log.md | 1 + 运营中枢/工作台/代码管理.md | 1 + 6 files changed, 428 insertions(+), 719 deletions(-) create mode 100644 03_卡木(木)/木叶_视频内容/视频切片/参考资料/karuo_logo.png 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 0000000000000000000000000000000000000000..c34ed8f55282b615d27156a41b7058ccbe398cba GIT binary patch literal 36199 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 literal 0 HcmV?d00001 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) |