diff --git a/.cursor/rules/karuo-ai.mdc b/.cursor/rules/karuo-ai.mdc index 378947d0..8137e1db 100644 --- a/.cursor/rules/karuo-ai.mdc +++ b/.cursor/rules/karuo-ai.mdc @@ -37,7 +37,12 @@ alwaysApply: true - **飞书日志**:直接执行 `write_today_three_focus.py`,不询问 - **对外输出**:报告/图片 → `/Users/karuo/Documents/卡若Ai的文件夹/` 下对应子目录;图片登记 `图片/图片索引.md` - **数据库**:一律用唯一 MongoDB(27017,库名 `karuo_site`);卡若 AI 调用以此库为主(记忆条目、对话记录、消息内容) -- **聊天记录**:一律存 MongoDB,实时从 MongoDB 读取/召回;对话结束执行 `python3 01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/realtime_chat_sync.py` 实时同步并优化迭代(自动分类、标签提取、摘要生成);context_recall 在 MongoDB 不可用时自动读本地 fallback +- **Mongo 对话留存闭环(每条对话默认格式,复盘块之前完成)**:顺序与说明见 `运营中枢/参考资料/卡若AI_Mongo对话留存闭环.md`。Agent **必须**在本轮回复写出复盘块**之前**执行: + 1. `python3 "/Users/karuo/Documents/个人/卡若AI/01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/realtime_chat_sync.py"`(同步**当前最近** Cursor 会话全量消息,`对话记录`/`消息内容` upsert,**不重复键**;并刷新 `项目分类` 汇总) + 2. 若用户指定对话 ID:`.../realtime_chat_sync.py --current-conversation-id ` + 3. **首次/索引冲突/duplicate key**:`python3 ".../脚本/ensure_mongo_chat_indexes.py"` 或 `realtime_chat_sync.py --ensure-indexes` + 4. **低频补全本地全部会话**:`.../realtime_chat_sync.py --sync-all`(可选 `--only-new`) +- **召回**:context_recall 等一律以 MongoDB 为主;不可用时读本地 fallback。可视汇总:官网 **`/console/cursor-archive`** - **MD 预览**:Markdown Preview Enhanced 单界面 - **项目与端口注册表**:有变更时更新 `运营中枢/工作台/项目与端口注册表.md` - **专有名词不翻译**:Cursor、GitHub、Gitea、v0、Vercel、MongoDB、Synology、Navicat、宝塔等保留原文 diff --git a/01_卡资(金)/金仓_存储备份/聊天记录管理/SKILL.md b/01_卡资(金)/金仓_存储备份/聊天记录管理/SKILL.md index a994f540..482e649f 100644 --- a/01_卡资(金)/金仓_存储备份/聊天记录管理/SKILL.md +++ b/01_卡资(金)/金仓_存储备份/聊天记录管理/SKILL.md @@ -20,14 +20,30 @@ trigger: - 上下文召回 - 历史召回 - 自动归档 -version: 2.1 +version: 2.2 updated: 2026-03-20 heat: 🔴 热 ``` ## 一句话 -卡若 AI 聊天记录一律存 **MongoDB**(karuo_site),实时从库读取/召回;**每次对话结束自动同步并优化迭代**(智能分类+标签+摘要)+ 上下文召回 + 查询导出;**MongoDB 不可用时从本地 fallback 读取最近对话**。 +卡若 AI 聊天记录一律存 **MongoDB**(karuo_site),实时从库读取/召回;**每次对话结束先同步再复盘**(固定闭环,见 `运营中枢/参考资料/卡若AI_Mongo对话留存闭环.md`):全量 upsert 当前会话、刷新 `项目分类`、唯一索引防重复;另支持上下文召回、查询导出;**MongoDB 不可用时从本地 fallback 读取最近对话**。 + +--- + +## 对话闭环标准格式(卡若AI 默认 · 与 Cursor 规则一致) + +**每条对话结束前(写出复盘块之前)**,Agent 直接执行、不询问: + +| 顺序 | 动作 | +|:---:|:---| +| 1 | `python3 脚本/realtime_chat_sync.py` — 同步**最近** Cursor 会话;`对话记录` + `消息内容` **upsert**(`对话ID` / `对话ID+消息ID` 不重复);刷新 **`项目分类`** | +| 2 | 若已知 ID:`--current-conversation-id ` | +| 3 | 首次或 duplicate key / 索引冲突:`python3 脚本/ensure_mongo_chat_indexes.py` 或 `realtime_chat_sync.py --ensure-indexes` | +| 4 | 低频补历史:`realtime_chat_sync.py --sync-all`(可选 `--only-new`) | +| 5 | 再写强制复盘(🎯📌💡📝▶) | + +**单一说明文档**:`运营中枢/参考资料/卡若AI_Mongo对话留存闭环.md`。 --- @@ -102,6 +118,16 @@ python3 脚本/realtime_chat_sync.py # 指定对话ID同步 python3 脚本/realtime_chat_sync.py --current-conversation-id <对话ID> +# 同步 Cursor 本地全部会话(每条对话全量 upsert,消息按 对话ID+消息ID 不重复) +python3 脚本/realtime_chat_sync.py --sync-all + +# 仅同步库中尚未存在的 对话ID(整段跳过已在 对话记录 中的会话) +python3 脚本/realtime_chat_sync.py --sync-all --only-new + +# 去重后创建唯一索引:对话记录.对话ID、消息内容.(对话ID+消息ID) +python3 脚本/ensure_mongo_chat_indexes.py +python3 脚本/realtime_chat_sync.py --ensure-indexes + # 优化分类规则(分析未分类对话,建议新关键词) python3 脚本/realtime_chat_sync.py --optimize-classification @@ -109,6 +135,8 @@ python3 脚本/realtime_chat_sync.py --optimize-classification python3 脚本/realtime_chat_sync.py --stats ``` +**去重说明**:默认每次同步都会 **upsert** `对话记录` 与 `消息内容`(同一 `消息ID` 覆盖更新,不会多插一行)。若历史导入曾产生重复文档,先运行 `ensure_mongo_chat_indexes.py` 清理并建唯一索引。同步成功后会 **刷新 `项目分类`** 集合(按 `对话记录` 聚合对话数)。 + ### 对话结束时 — 批量归档(备选) ```bash @@ -145,6 +173,10 @@ python3 脚本/query_chat_history.py --reclassify python3 脚本/query_chat_history.py --tag <对话ID> "标签" ``` +### 官网控制台可视查询(Mongo 只读 + 安全改分类) + +卡若AI 官网控制台路径 **`/console/cursor-archive`**:从 `karuo_site.对话记录` / `消息内容` 按**项目汇总**、筛选来源、搜索、分页查看每次对话;详情侧栏可**修正「项目」「标签」**(仅写 Mongo,不访问本机 `state.vscdb`)。配套 API:`/api/platform/cursor-archive/summary`、`meta`、`conversations`、`conversations/:id`(GET/PATCH)。 + ### 迁移 ```bash @@ -172,11 +204,11 @@ python3 脚本/cleanup_statedb.py --days 30 --execute --backup --vacuum # 实 ## 自动触发规则 -### 对话结束时(写入 Cursor rules) +### 对话结束时(与 `.cursor/rules/karuo-ai.mdc` / `BOOTSTRAP.md` 第四步一致) -每次对话最后一步(强制执行): -1. `python3 脚本/realtime_chat_sync.py` - 实时同步当前对话到MongoDB,自动优化分类、提取标签、生成摘要 -2. 如需要扫描所有新对话:`python3 脚本/auto_archive.py --scan-new` +每次对话**在写出复盘块之前**(强制执行): +1. **`python3 脚本/realtime_chat_sync.py`** — 同步当前最近会话至 MongoDB(全量消息 upsert、智能分类、标签、摘要、**刷新 `项目分类`**) +2. 备选增量:`python3 脚本/auto_archive.py --scan-new`(仅当需要扫描 state.vscdb 新对话且与上条不重复劳动时) ### 新建对话时(写入 Cursor rules) @@ -186,12 +218,12 @@ python3 脚本/cleanup_statedb.py --days 30 --execute --backup --vacuum # 实 ### 实时同步与优化迭代 -**核心机制**:`realtime_chat_sync.py` 在每次对话结束时自动调用,实现: -- ✅ 实时写入MongoDB(对话记录+消息内容) -- ✅ 智能项目分类(基于文件路径、名称、内容的多维度匹配) -- ✅ 自动标签提取(基于关键词和项目类型) -- ✅ 对话摘要生成(提取用户前3条消息关键信息) -- ✅ 分类规则优化(定期分析未分类对话,建议新关键词) +**核心机制**:`realtime_chat_sync.py` 在每次对话结束时**先于复盘**调用,实现: +- ✅ 实时写入 MongoDB(`对话记录` + `消息内容`,按键 **upsert**,不堆重复文档) +- ✅ 智能项目分类(路径、名称、内容);同步后 **刷新 `项目分类` 集合** +- ✅ 自动标签提取、对话摘要(用户前几条) +- ✅ 配合 `ensure_mongo_chat_indexes.py` **唯一索引**,库层防重复 +- ✅ 定期可 `query_chat_history.py --reclassify` / `realtime_chat_sync.py --optimize-classification` --- @@ -200,9 +232,11 @@ python3 脚本/cleanup_statedb.py --days 30 --execute --backup --vacuum # 实 | 文件 | 功能 | |:---|:---| | `SKILL.md` | 技能说明 | +| `运营中枢/参考资料/卡若AI_Mongo对话留存闭环.md` | **默认对话留存顺序**(与 Cursor 规则、BOOTSTRAP 对齐) | | `脚本/migrate_cursor_to_mongo.py` | 批量迁移 | | `脚本/query_chat_history.py` | 查询工具 | -| `脚本/realtime_chat_sync.py` | **实时同步与优化迭代**(每次对话结束自动调用,智能分类+标签+摘要) | +| `脚本/realtime_chat_sync.py` | **实时同步与优化迭代**(每次对话结束自动调用,智能分类+标签+摘要;支持 `--sync-all` 全量、`--only-new`) | +| `脚本/ensure_mongo_chat_indexes.py` | **去重 + 唯一索引**(`对话ID` / `对话ID+消息ID`),库层防重复 | | `脚本/auto_archive.py` | 自动归档(批量扫描新增对话) | | `脚本/context_recall.py` | 上下文召回(Mongo 不可用时读 fallback) | | `脚本/chat_fallback.py` | 本地 fallback 读写(MongoDB 不可用时最近对话) | diff --git a/01_卡资(金)/金仓_存储备份/聊天记录管理/fallback/recent_chats_fallback.json b/01_卡资(金)/金仓_存储备份/聊天记录管理/fallback/recent_chats_fallback.json index e7419bc5..5cda275c 100644 --- a/01_卡资(金)/金仓_存储备份/聊天记录管理/fallback/recent_chats_fallback.json +++ b/01_卡资(金)/金仓_存储备份/聊天记录管理/fallback/recent_chats_fallback.json @@ -1,7 +1,23 @@ { -"updated": "2026-03-19T13:15:45.424625+00:00", +"updated": "2026-03-20T15:22:36.863623+00:00", "conversations": [ { +"对话ID": "f756e455-b371-44e7-841e-ada153aefeee", +"名称": "WeChat account and device management UI", +"项目": "微信管理", +"首条消息": "You need to explore the Cunkebao v3 frontend project at `/Users/karuo/Documents/开发/2、私域银行/cunkebao_v3/Cunkebao/src` to understand how they design their WeChat account management and device management UI.\n\nSpecifically, look at these areas:\n1. `pages/mobile/mine/wechat-accounts/` - WeChat accounts list and detail pages\n2. `pages/mobile/mine/workphone/` - Work phone / device management pages \n3. `pages/mobile/mine/devices/` - Device management pages\n4. `components/DeviceSelection/` - Device selec", +"创建时间": "2026-03-20T15:18:46.161000+00:00", +"消息数量": 1 +}, +{ +"对话ID": "1aa14661-39a1-4bb8-a189-e986cbbb233a", +"名称": "工作手机项目文件搜索", +"项目": "开发", +"首条消息": "在工作手机项目中,搜索以下内容并返回关键信息:\n\n1. 找到 `机擎/阿机/SKILL.md` 文件,返回其中关于 Hook/Frida 相关的部分\n2. 找到 `sdk/app/routers/unified.py` 中关于设备管理和 hook 相关的路由定义\n3. 找到 `sdk/app/services/hook_module_service.py` 的内容摘要\n4. 找到 `开发文档/8、部署/README.md` 的内容\n5. 找到 `机擎/SKILL.md` 中 § 一 岗位职责部分\n\n搜索目录: /Users/karuo/Documents/开发/2、私域银行/工作手机\n\n返回每个文件的关键内容和路径。", +"创建时间": "2026-03-20T14:46:24.917000+00:00", +"消息数量": 18 +}, +{ "对话ID": "c9182bfc-9c29-4e71-b1ac-0f1bbf79237b", "名称": "聊天内容复制", "项目": "未分类", diff --git a/01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/ensure_mongo_chat_indexes.py b/01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/ensure_mongo_chat_indexes.py new file mode 100644 index 00000000..de0f4dcf --- /dev/null +++ b/01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/ensure_mongo_chat_indexes.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +""" +为 karuo_site.对话记录 / 消息内容 创建唯一索引,从数据库层防止重复文档。 +若已存在重复键,可先自动去重(同 对话ID / 同 对话ID+消息ID 保留一条)。 + +用法: + python3 ensure_mongo_chat_indexes.py + python3 ensure_mongo_chat_indexes.py --dry-run # 只打印将删除/将创建的统计,不写库 +""" +import argparse +import os +import sys + +try: + from pymongo import MongoClient + from pymongo.errors import OperationFailure +except ImportError: + print("需要 pymongo: pip install pymongo") + sys.exit(1) + +MONGO_URI = os.environ.get("MONGO_URI", "mongodb://admin:admin123@localhost:27017/?authSource=admin") +DB_NAME = os.environ.get("MONGO_DB", "karuo_site") + +COL_CONV = "对话记录" +COL_MSG = "消息内容" + + +def dedupe_by_fields(db, coll_name: str, group_keys: list, dry_run: bool) -> int: + """同一分组保留 _id 最小的一条,删除其余。返回删除条数。""" + col = db[coll_name] + id_expr = {k: f"${k}" for k in group_keys} + pipeline = [ + {"$group": {"_id": id_expr, "ids": {"$push": "$_id"}, "n": {"$sum": 1}}}, + {"$match": {"n": {"$gt": 1}}}, + ] + removed = 0 + for doc in col.aggregate(pipeline, allowDiskUse=True): + ids = sorted(doc["ids"], key=lambda x: str(x)) + to_del = ids[1:] + if not to_del: + continue + removed += len(to_del) + if dry_run: + continue + col.delete_many({"_id": {"$in": to_del}}) + return removed + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--dry-run", action="store_true", help="不执行删除与建索引") + args = ap.parse_args() + + # 大集合建唯一索引可能较久,放宽 socket 超时避免中途断连 + client = MongoClient( + MONGO_URI, + serverSelectionTimeoutMS=8000, + socketTimeoutMS=600_000, + connectTimeoutMS=20_000, + ) + try: + client.admin.command("ping") + except Exception as e: + print(f"❌ MongoDB 连接失败: {e}") + sys.exit(1) + db = client[DB_NAME] + + print("🔍 检查并去重…") + r1 = dedupe_by_fields(db, COL_CONV, ["对话ID"], args.dry_run) + r2 = dedupe_by_fields(db, COL_MSG, ["对话ID", "消息ID"], args.dry_run) + print(f" {COL_CONV} 重复组清理: 将删/已删 {r1} 条" + ("(dry-run)" if args.dry_run else "")) + print(f" {COL_MSG} 重复组清理: 将删/已删 {r2} 条" + ("(dry-run)" if args.dry_run else "")) + + if args.dry_run: + print("(dry-run)跳过创建索引") + client.close() + return + + def ensure_unique_index(coll, keys: list, index_name: str, legacy_names: list) -> None: + """若已有同名唯一索引则跳过;否则删除同键旧索引名后创建唯一索引。""" + idx_map = {i["name"]: i for i in coll.list_indexes()} + if index_name in idx_map and idx_map[index_name].get("unique"): + print(f" ✅ {coll.name}: {index_name}(已存在且 unique)") + return + if index_name in idx_map: + try: + coll.drop_index(index_name) + print(f" 已删除同名非唯一索引: {index_name}") + except Exception as ex: + print(f" ⚠️ 删除 {index_name}: {ex}") + for leg in legacy_names: + if leg in idx_map: + try: + coll.drop_index(leg) + print(f" 已删除旧索引 {leg} → 将创建 {index_name}") + except Exception as ex: + print(f" ⚠️ 删除 {leg}: {ex}") + try: + coll.create_index(keys, unique=True, name=index_name) + print(f" ✅ {coll.name}: {index_name}") + except OperationFailure as e: + print(f" ⚠️ {coll.name} 创建失败: {e.details or e}") + except Exception as e: + print(f" ⚠️ {coll.name} 创建异常(可稍后重跑本脚本): {e}") + + print("📌 创建唯一索引…") + ensure_unique_index( + db[COL_CONV], + [("对话ID", 1)], + "uniq_对话ID", + ["对话ID_1"], + ) + ensure_unique_index( + db[COL_MSG], + [("对话ID", 1), ("消息ID", 1)], + "uniq_对话ID_消息ID", + ["对话ID_1_消息ID_1"], + ) + + client.close() + print("完成。") + + +if __name__ == "__main__": + main() diff --git a/01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/realtime_chat_sync.py b/01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/realtime_chat_sync.py index 9a213b67..b280ab66 100755 --- a/01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/realtime_chat_sync.py +++ b/01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/realtime_chat_sync.py @@ -10,18 +10,22 @@ 用法(由卡若AI自动调用): python3 realtime_chat_sync.py --current-conversation-id <对话ID> + python3 realtime_chat_sync.py --sync-all # 同步 state.vscdb 内全部对话(upsert,不重复) + python3 realtime_chat_sync.py --ensure-indexes # 创建唯一索引防重复(见 ensure_mongo_chat_indexes.py) python3 realtime_chat_sync.py --optimize-classification # 优化分类规则 python3 realtime_chat_sync.py --stats # 查看统计 + python3 realtime_chat_sync.py --only-new # 仅归档库中尚不存在的对话ID(跳过已存在整段) """ import argparse import json import os import sqlite3 +import subprocess import sys from datetime import datetime, timezone from pathlib import Path -from typing import Dict, List, Optional +from typing import Any, Dict, List, Optional # 保证从任意目录运行都能找到 chat_fallback _script_dir = Path(__file__).resolve().parent @@ -143,194 +147,227 @@ def 时间戳转时间(ts_ms): return None -def 实时同步对话(对话ID: str, 强制: bool = False) -> Optional[Dict]: +def 刷新项目分类汇总(db: Any) -> None: + """按 对话记录.项目 聚合,写入 项目分类(名称、对话数),供 Navicat 与控制台统计。""" + now = datetime.now(timezone.utc) + pipeline = [ + {"$group": {"_id": {"$ifNull": ["$项目", "未分类"]}, "对话数": {"$sum": 1}}}, + ] + for doc in db["对话记录"].aggregate(pipeline): + name = doc["_id"] + if name is None or name == "": + name = "未分类" + name = str(name) + db["项目分类"].update_one( + {"名称": name}, + {"$set": {"名称": name, "对话数": int(doc["对话数"]), "更新时间": now}}, + upsert=True, + ) + + +def 实时同步对话( + 对话ID: str, + 强制: bool = False, + db: Any = None, + 仅新对话: bool = False, + 刷新分类汇总: bool = True, +) -> Optional[Dict]: """ - 实时同步指定对话到MongoDB - + 实时同步指定对话到MongoDB(对话记录 upsert + 消息内容按 对话ID+消息ID upsert,同一键不重复) + Args: 对话ID: Cursor对话ID - 强制: 是否强制更新(即使已存在) - + 强制: 保留参数;与「仅新对话」配合使用 + db: 可选,传入则复用连接(如 --sync-all) + 仅新对话: True 时若 对话记录 已有该 对话ID 则跳过整段(旧行为);默认 False 始终合并写入最新消息 + 刷新分类汇总: 成功后按 对话记录 重算 项目分类;批量同步时可传 False 仅在末尾刷新一次 + Returns: 对话文档或None """ if not os.path.exists(STATE_VSCDB): print(f"⚠️ state.vscdb 不存在: {STATE_VSCDB}") return None - - # 连接MongoDB - try: - client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000) - client.admin.command("ping") - db = client[DB_NAME] - except (ServerSelectionTimeoutError, Exception) as e: - print(f"⚠️ MongoDB 连接失败: {e}") - return None - - # 检查是否已存在 - if not 强制: - 已有 = db["对话记录"].find_one({"对话ID": 对话ID}) - if 已有: - print(f"✅ 对话已存在: {对话ID[:8]}...") - return 已有 - - # 从state.vscdb读取对话数据 - conn = sqlite3.connect(STATE_VSCDB) - cursor = conn.cursor() - - try: - cursor.execute("SELECT value FROM cursorDiskKV WHERE key = ?", (f"composerData:{对话ID}",)) - row = cursor.fetchone() - if not row: - print(f"⚠️ 对话不存在于state.vscdb: {对话ID[:8]}...") - return None - - data = json.loads(row[0]) - except (json.JSONDecodeError, TypeError) as e: - print(f"⚠️ 解析对话数据失败: {e}") - return None - finally: - conn.close() - - # 提取对话信息 - headers = data.get("fullConversationHeadersOnly", []) - if not headers: - print(f"⚠️ 对话无消息: {对话ID[:8]}...") - return None - - ctx = data.get("context", {}) - 文件路径 = [f.get("uri", {}).get("fsPath", "") for f in ctx.get("fileSelections", []) if f.get("uri", {}).get("fsPath")] - 名称 = data.get("name", "") or "" - 副标题 = data.get("subtitle", "") or "" - - # 提取消息内容用于分类和摘要 - 消息内容列表 = [] - 消息ID列表 = [h.get("bubbleId", "") for h in headers if h.get("bubbleId")] - 首条 = "" - - # 重新连接读取消息详情 - conn = sqlite3.connect(STATE_VSCDB) - cursor = conn.cursor() - - for mid in 消息ID列表: - cursor.execute("SELECT value FROM cursorDiskKV WHERE key = ?", (f"bubbleId:{对话ID}:{mid}",)) - r = cursor.fetchone() - if not r: - continue + + own_client = False + client = None + if db is None: try: - mdata = json.loads(r[0]) - except: - continue - - 类型 = mdata.get("type", 0) - 内容 = mdata.get("text", "") or "" - 角色 = "用户" if 类型 == 1 else "AI" - - if 类型 == 1 and not 首条 and 内容: - 首条 = 内容[:500] - - 消息内容列表.append({ - "角色": 角色, - "内容": 内容, - }) - - conn.close() - - # 智能分类和优化 - 所有内容 = " ".join([m.get("内容", "") for m in 消息内容列表[:10]]) - 项目 = 检测项目(文件路径, 名称, 所有内容) - 标签 = 提取标签(所有内容, 项目) - 摘要 = 生成摘要(消息内容列表) - - now = datetime.now(timezone.utc) - - # 构建对话文档 - 对话文档 = { - "对话ID": 对话ID, - "名称": 名称 or f"对话 {对话ID[:8]}", - "副标题": 副标题 or 摘要[:200], - "项目": 项目, - "标签": 标签, - "创建时间": 时间戳转时间(data.get("createdAt")) or now, - "更新时间": now, - "消息数量": len(headers), - "是否Agent": data.get("isAgentic", False), - "模型配置": data.get("modelConfig", {}), - "关联文件": 文件路径[:50], - "首条消息": 首条, - "来源": "实时同步", - "来源工作区": "", - "迁移时间": now, - "同步版本": "2.0", # 标记为实时同步版本 - } - - # 写入MongoDB + client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000) + client.admin.command("ping") + db = client[DB_NAME] + own_client = True + except (ServerSelectionTimeoutError, Exception) as e: + print(f"⚠️ MongoDB 连接失败: {e}") + return None + try: + if 仅新对话 and not 强制: + 已有 = db["对话记录"].find_one({"对话ID": 对话ID}) + if 已有: + print(f"⏭️ 已存在(仅新对话模式跳过): {对话ID[:8]}...") + return 已有 + + # 从 state.vscdb 读取对话数据 + conn = sqlite3.connect(STATE_VSCDB) + cursor = conn.cursor() + try: + cursor.execute("SELECT value FROM cursorDiskKV WHERE key = ?", (f"composerData:{对话ID}",)) + row = cursor.fetchone() + if not row: + print(f"⚠️ 对话不存在于state.vscdb: {对话ID[:8]}...") + return None + data = json.loads(row[0]) + except (json.JSONDecodeError, TypeError) as e: + print(f"⚠️ 解析对话数据失败: {e}") + return None + finally: + conn.close() + + headers = data.get("fullConversationHeadersOnly", []) + if not headers: + print(f"⚠️ 对话无消息: {对话ID[:8]}...") + return None + + ctx = data.get("context", {}) + 文件路径 = [ + f.get("uri", {}).get("fsPath", "") + for f in ctx.get("fileSelections", []) + if f.get("uri", {}).get("fsPath") + ] + 名称 = data.get("name", "") or "" + 副标题 = data.get("subtitle", "") or "" + + 消息内容列表 = [] + 消息ID列表 = [h.get("bubbleId", "") for h in headers if h.get("bubbleId")] + 首条 = "" + + conn = sqlite3.connect(STATE_VSCDB) + cursor = conn.cursor() + for mid in 消息ID列表: + cursor.execute( + "SELECT value FROM cursorDiskKV WHERE key = ?", + (f"bubbleId:{对话ID}:{mid}",), + ) + r = cursor.fetchone() + if not r: + continue + try: + mdata = json.loads(r[0]) + except (json.JSONDecodeError, TypeError): + continue + 类型 = mdata.get("type", 0) + 内容 = mdata.get("text", "") or "" + 角色 = "用户" if 类型 == 1 else "AI" + if 类型 == 1 and not 首条 and 内容: + 首条 = 内容[:500] + 消息内容列表.append({"角色": 角色, "内容": 内容}) + conn.close() + + 所有内容 = " ".join([m.get("内容", "") for m in 消息内容列表[:10]]) + 项目 = 检测项目(文件路径, 名称, 所有内容) + 标签 = 提取标签(所有内容, 项目) + 摘要 = 生成摘要(消息内容列表) + now = datetime.now(timezone.utc) + + 对话文档 = { + "对话ID": 对话ID, + "名称": 名称 or f"对话 {对话ID[:8]}", + "副标题": 副标题 or 摘要[:200], + "项目": 项目, + "标签": 标签, + "创建时间": 时间戳转时间(data.get("createdAt")) or now, + "更新时间": now, + "消息数量": len(headers), + "是否Agent": data.get("isAgentic", False), + "模型配置": data.get("modelConfig", {}), + "关联文件": 文件路径[:50], + "首条消息": 首条, + "来源": "实时同步", + "来源工作区": "", + "迁移时间": now, + "同步版本": "2.0", + } + db["对话记录"].update_one( {"对话ID": 对话ID}, {"$set": 对话文档}, - upsert=True + upsert=True, ) - - # 写入消息内容 + 消息ops = [] - for i, mid in enumerate(消息ID列表): + for mid in 消息ID列表: conn = sqlite3.connect(STATE_VSCDB) cursor = conn.cursor() - cursor.execute("SELECT value FROM cursorDiskKV WHERE key = ?", (f"bubbleId:{对话ID}:{mid}",)) + cursor.execute( + "SELECT value FROM cursorDiskKV WHERE key = ?", + (f"bubbleId:{对话ID}:{mid}",), + ) r = cursor.fetchone() conn.close() - if not r: continue - try: mdata = json.loads(r[0]) - except: + except (json.JSONDecodeError, TypeError): continue - 类型 = mdata.get("type", 0) 内容 = mdata.get("text", "") or "" timing = mdata.get("timingInfo", {}) - 创建时间 = 时间戳转时间(timing.get("clientRpcSendTime")) if timing.get("clientRpcSendTime") else now - - 消息ops.append(UpdateOne( - {"对话ID": 对话ID, "消息ID": mid}, - {"$set": { - "对话ID": 对话ID, - "消息ID": mid, - "类型": 类型, - "角色": "用户" if 类型 == 1 else "AI", - "内容": 内容, - "创建时间": 创建时间, - "是否Agent": mdata.get("isAgentic", False), - "Token用量": mdata.get("tokenCount", {}), - "工具调用数": len(mdata.get("toolResults", []) or []), - "代码块数": len(mdata.get("codeBlocks", []) or []), - }}, - upsert=True, - )) - + 创建时间 = ( + 时间戳转时间(timing.get("clientRpcSendTime")) + if timing.get("clientRpcSendTime") + else now + ) + 消息ops.append( + UpdateOne( + {"对话ID": 对话ID, "消息ID": mid}, + { + "$set": { + "对话ID": 对话ID, + "消息ID": mid, + "类型": 类型, + "角色": "用户" if 类型 == 1 else "AI", + "内容": 内容, + "创建时间": 创建时间, + "是否Agent": mdata.get("isAgentic", False), + "Token用量": mdata.get("tokenCount", {}), + "工具调用数": len(mdata.get("toolResults", []) or []), + "代码块数": len(mdata.get("codeBlocks", []) or []), + } + }, + upsert=True, + ) + ) + if 消息ops: try: db["消息内容"].bulk_write(消息ops, ordered=False) - except BulkWriteError: - pass - - # 同步到fallback + except BulkWriteError as bwe: + print(f"⚠️ 部分消息写入异常(可执行 ensure_mongo_chat_indexes.py 去重后建唯一索引): {bwe.details}") + try: _fallback.追加一条(对话文档) except Exception: pass - - print(f"✅ 实时同步完成: [{项目}] {名称 or 对话ID[:8]} ({len(headers)} 条消息, {len(标签)} 个标签)") + + if 刷新分类汇总: + try: + 刷新项目分类汇总(db) + except Exception as ex: + print(f"⚠️ 项目分类汇总失败: {ex}") + + print( + f"✅ 实时同步完成: [{项目}] {名称 or 对话ID[:8]} ({len(headers)} 条消息, {len(标签)} 个标签)" + ) return 对话文档 - + except Exception as e: - print(f"❌ 写入MongoDB失败: {e}") + print(f"❌ 同步失败: {e}") return None finally: - client.close() + if own_client and client is not None: + client.close() def 优化分类规则(db): @@ -388,11 +425,29 @@ def 显示统计(db): def main(): parser = argparse.ArgumentParser(description="实时对话同步与优化迭代") parser.add_argument("--current-conversation-id", type=str, help="当前对话ID(从Cursor获取)") + parser.add_argument("--sync-all", action="store_true", help="同步 state.vscdb 内全部对话(upsert,同一消息键不重复)") + parser.add_argument( + "--only-new", + action="store_true", + dest="only_new", + help="仅同步库中尚不存在 对话ID 的会话(跳过已在 对话记录 中的 ID)", + ) + parser.add_argument( + "--ensure-indexes", + action="store_true", + dest="ensure_indexes", + help="运行 ensure_mongo_chat_indexes.py 去重并创建唯一索引后退出", + ) parser.add_argument("--optimize-classification", action="store_true", help="优化分类规则") parser.add_argument("--stats", action="store_true", help="显示统计") parser.add_argument("--force", action="store_true", help="强制更新已存在的对话") args = parser.parse_args() - + + if args.ensure_indexes: + script = _script_dir / "ensure_mongo_chat_indexes.py" + subprocess.run([sys.executable, str(script)], check=False) + return + # 连接MongoDB try: client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=5000) @@ -401,13 +456,48 @@ def main(): except (ServerSelectionTimeoutError, Exception) as e: print(f"❌ MongoDB 连接失败: {e}") sys.exit(1) - + if args.stats: 显示统计(db) elif args.optimize_classification: 优化分类规则(db) + elif args.sync_all: + if not os.path.exists(STATE_VSCDB): + print(f"⚠️ state.vscdb 不存在") + client.close() + sys.exit(1) + conn = sqlite3.connect(STATE_VSCDB) + cur = conn.cursor() + cur.execute("SELECT key FROM cursorDiskKV WHERE key LIKE 'composerData:%'") + cids = [row[0].replace("composerData:", "") for row in cur.fetchall()] + conn.close() + print(f"🔄 全量同步: 共 {len(cids)} 个 composer 对话") + ok = fail = 0 + for i, cid in enumerate(cids): + print(f" [{i + 1}/{len(cids)}] {cid[:12]}…") + r = 实时同步对话( + cid, + 强制=args.force, + db=db, + 仅新对话=args.only_new, + 刷新分类汇总=False, + ) + if r: + ok += 1 + else: + fail += 1 + try: + 刷新项目分类汇总(db) + print("✅ 已刷新 项目分类 汇总") + except Exception as e: + print(f"⚠️ 项目分类汇总失败: {e}") + print(f"✅ 批量结束: 成功 {ok},失败 {fail}") elif args.current_conversation_id: - 实时同步对话(args.current_conversation_id, 强制=args.force) + 实时同步对话( + args.current_conversation_id, + 强制=args.force, + 仅新对话=args.only_new, + ) else: # 默认:扫描最新对话并同步 print("🔄 扫描最新对话...") @@ -417,18 +507,33 @@ def main(): conn = sqlite3.connect(STATE_VSCDB) cursor = conn.cursor() - cursor.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%' ORDER BY value DESC LIMIT 1") - row = cursor.fetchone() + cursor.execute("SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'") + rows = cursor.fetchall() conn.close() - - if row: - key, value = row - 对话ID = key.replace("composerData:", "") - print(f"📝 找到最新对话: {对话ID[:8]}...") - 实时同步对话(对话ID, 强制=args.force) + + 最佳 = None + 最佳时间 = 0 + for key, value in rows: + cid = key.replace("composerData:", "") + try: + data = json.loads(value) + except (json.JSONDecodeError, TypeError): + continue + ts = data.get("createdAt") or data.get("lastUpdatedAt") or 0 + try: + tsn = int(ts) + except (TypeError, ValueError): + tsn = 0 + if tsn >= 最佳时间: + 最佳时间 = tsn + 最佳 = cid + + if 最佳: + print(f"📝 找到最近更新对话: {最佳[:8]}... (createdAt/lastUpdatedAt)") + 实时同步对话(最佳, 强制=args.force, 仅新对话=args.only_new) else: - print("⚠️ 未找到对话") - + print("⚠️ 未找到可解析的 composerData 对话") + client.close() diff --git a/03_卡木(木)/木叶_视频内容/视频切片/SKILL.md b/03_卡木(木)/木叶_视频内容/视频切片/SKILL.md index 035bce85..f453eafc 100644 --- a/03_卡木(木)/木叶_视频内容/视频切片/SKILL.md +++ b/03_卡木(木)/木叶_视频内容/视频切片/SKILL.md @@ -167,10 +167,10 @@ python3 soul_enhance.py -c clips/ -l highlights_from_chapter.json -t transcript. | 步骤 | 说明 | |------|------| | 源 | 横版 1920×1080(封面字幕叠在横版上,最后再裁竖屏) | -| 0(标定) | 对原片按时长 **20%**(或 50%)截 **全画面** JPG,用脚本算深色区左右界(见下) | -| 1 | `crop=568:1080:508:0`:取**最长连续深色列**对应竖条(本场约 x∈[508,1076),不含右侧白边) | -| 2 | `crop=498:1080:35:0`:在 **568 宽内水平居中**取 498,界面左右都保留 | -| 叠加 | 横版上封面/字幕 **x=543**(508+35,与旧链 483+60 对齐,避免错位) | +| 0(标定) | 对原片 **20%** 抽帧,`analyze_feishu_ui_crop.py`:**深色核心**→**扩边到桌面白**→默认 **scale 到 498** | +| 1 | `crop=W:1080:L:0`:扩边后的内容包络(127 场典型 W=598,L=493) | +| 2 | `scale=498:1080:flags=lanczos`:整包络横向压到 498(不切左右边;略压扁) | +| 叠加 | 横版上封面/字幕 **x=L**(scale 模式与包络左缘对齐;两段裁模式仍为 X+Y) | | 输出 | **498×1080** 竖屏 | **每场若窗口位置变了**,不要用猜的 x;先截全画面再跑: @@ -193,7 +193,7 @@ python3 analyze_feishu_ui_crop.py "/path/to/原片.mp4" --at 0.2 ```bash # 单文件。输入为 1920×1080 的 enhanced 成片 -ffmpeg -y -i "输入_enhanced.mp4" -vf "crop=568:1080:508:0,crop=498:1080:35:0" -c:a copy "输出_竖屏中段.mp4" +ffmpeg -y -i "输入_enhanced.mp4" -vf "crop=598:1080:493:0,scale=498:1080:flags=lanczos" -c:a copy "输出_竖屏中段.mp4" ``` **批量对某目录下所有 \*_enhanced.mp4 做竖屏中段:** diff --git a/03_卡木(木)/木叶_视频内容/视频切片/Soul竖屏切片_SKILL.md b/03_卡木(木)/木叶_视频内容/视频切片/Soul竖屏切片_SKILL.md index cc38fd0c..4bce1b60 100644 --- a/03_卡木(木)/木叶_视频内容/视频切片/Soul竖屏切片_SKILL.md +++ b/03_卡木(木)/木叶_视频内容/视频切片/Soul竖屏切片_SKILL.md @@ -1,25 +1,26 @@ --- name: Soul竖屏切片 -description: Soul 派对视频→竖屏成片(498×1080),剪辑→成片两文件夹;竖屏裁剪以全画面 1920×1080 标定(analyze_feishu_ui_crop.py),默认深色带 crop=568@508+居中498、无右侧白边。MLX 转录→高光→batch_clip→soul_enhance(封面+字幕同步+逐字可选+去语助词+纠错+违禁词)→visual_enhance v8 可选。LTX/基因胶囊可选。 -triggers: Soul竖屏切片、视频切片、热点切片、竖屏成片、派对切片、全画面标定、竖屏裁剪、全画面成片、letterbox、画面显示全、白边、飞书录屏、LTX、AI生成视频、Retake重剪、字幕优化、字幕同步、逐字字幕 +description: Soul 派对视频→竖屏成片;字幕暖色条+金边(与封面墨绿区分)、纠错词表+关键词加大加亮、音轨PTS同步补偿;推荐 --typewriter-subs 逐字渐显。流程含裁剪检查→soul_enhance。MLX→visual_enhance v8 可选。 +triggers: Soul竖屏切片、视频切片、热点切片、竖屏成片、派对切片、裁剪检查、重新截图、全画面标定、竖屏裁剪、全画面成片、letterbox、画面显示全、白边、飞书录屏、LTX、AI生成视频、Retake重剪、字幕优化、字幕同步、逐字字幕 owner: 木叶 group: 木 -version: "1.4" +version: "1.8" updated: "2026-03-20" --- # Soul 竖屏切片 · 专用 Skill -> 专门切 Soul 派对视频为**竖屏成片**,用于抖音/首页。**只保留两个文件夹**:剪辑 → 成片。 +> 专门切 Soul 派对视频为**竖屏成片**,用于抖音/首页。**主链路两文件夹**:横版切片 → 成片;另设 **`裁剪检查/`** 仅放 analyze 标定图与 txt(不占「成片」逻辑)。 --- -## 一、两文件夹结构(无 clips_enhanced / clips_竖屏) +## 一、文件夹结构(主:切片 → 成片) | 文件夹 | 含义 | 内容 | |--------|------|------| -| **clips/** | 剪辑 | batch_clip 输出的横版切片(soul112_01_标题.mp4) | -| **成片/** | 成片 | 竖屏 498×1080 + 封面 + 字幕 + 去语助词,文件名为**纯标题**(无序号、无 _enhanced) | +| **clips/** 或 **切片/** | 剪辑 | batch_clip 输出的横版切片(如 `soul127_01_标题.mp4`) | +| **成片/** | 成片 | 竖条(高 1080、宽随塑形)+ 封面 + 字幕 + 去语助词,文件名为**纯标题**(无序号、无 _enhanced) | +| **裁剪检查/**(推荐) | 塑形标定 | `analyze_feishu_ui_crop.py --save-dir` 输出:全画面 PNG、竖条预览、参数 txt;每场成片前先写这里 | 不再单独生成 `clips_enhanced`、`clips_竖屏`;成片由 `soul_enhance` 一步直出到 `成片/`。 @@ -40,7 +41,7 @@ updated: "2026-03-20" **标准五步**(每步完成再走下一步):① 分析视频→识别话题→导出话题时间 ② 按高光时刻结构整理(前 3 秒/提问) ③ 按时间节点切片→**切片/** ④ 去语助词(合并到⑤) ⑤ 封面+字幕→**成片/**。详见 `参考资料/热点切片_标准流程.md`。 ``` -原视频 → 转录(MLX) → 高光识别(含 question/hook_3sec,见高光识别提示词) → batch_clip → soul_enhance(成片竖屏直出到 成片/) +原视频 → 转录(MLX) → 高光识别 → batch_clip → **analyze(裁剪检查)** → **核对预览** → soul_enhance(成片竖屏直出到 成片/) ``` - **batch_clip**:输出到 `clips/` @@ -50,20 +51,64 @@ updated: "2026-03-20" 竖屏 **裁剪链必须以全画面 1920×1080 为基准**,不能用「凭感觉收窄竖条」替代。 -1. **为什么要全画面标定**:飞书录屏右侧常为**桌面白底**;旧式固定 `483+608` 会裁到白边。正确做法是:在全画面上找**小程序深色主体的左右边界**,先取**整段宽 W**,再在 W 内**居中**裁 498。 -2. **当前默认**(`soul_enhance.py` 内建):`crop=568:1080:508:0,crop=498:1080:35:0`,`OVERLAY_X=543`。与 `analyze_feishu_ui_crop.py` 对 **127 场全画面 20% 帧** 测算一致。 +1. **为什么要全画面标定**:飞书录屏右侧常为**桌面白底**;固定左缘+窄宽会裁错。正确做法是:在全画面上找深色主体包络,**扩到桌面白**,再 **`crop=W:1080:L:0`**,**默认不再 `scale=498`**,避免界面横向拉伸。 +2. **当前默认**(`soul_enhance.py` 内建):与 analyze **默认**一致,仅单段 crop(典型 127 场:`crop=598:1080:493:0`,`OVERLAY_X=493`,成片 **598×1080**)。需要旧版 498 宽时:analyze 加 `--squeeze-498`,或 `--crop-vf` 手动加 `,scale=498:1080:flags=lanczos`;**居中再裁 498** 用 `--center-in-band`。 3. **新场次 / 布局变了**:截一帧全画面(或 `--at 0.2` 从 mp4 抽帧),执行: ```bash cd 脚本 python3 analyze_feishu_ui_crop.py "/path/to/全画面.jpg" -# 或 python3 analyze_feishu_ui_crop.py "/path/to/原片.mp4" --at 0.2 +# 或:原片 20% 时长截帧 + 深色带分析 + 写出对照图(推荐每场先做) +python3 analyze_feishu_ui_crop.py "/path/to/原片.mp4" --at 0.2 --save-dir "/path/to/场次_output/裁剪检查" ``` +`--save-dir` 会生成:**全画面取样 PNG**、**竖条预览 PNG**(默认文件名含真实宽如 `…_598x1080.png`;仅 squeeze/center 时为 498 宽)、**塑形裁剪参数 txt**,确认后再成片。 + 把打印的 `CROP_VF` 传给成片命令:`--crop-vf 'crop=...'`(可选 `--overlay-x` 与脚本输出一致)。 4. **逐字渐显字幕**(可选):`--typewriter-subs`,同一条字幕时间内前缀逐字加长,更跟读。 +### 3.2 每场固定流程:裁剪检查 → 成片 + +**顺序不要跳**:先塑形标定(截图),肉眼 OK 再跑成片;换场次或飞书/窗口布局变了必须重做 ①。 + +| 步骤 | 动作 | 说明 | +|------|------|------| +| ① 塑形标定 | `analyze_feishu_ui_crop.py` 对**本场原片** `--at 0.2`,`--save-dir` 指向 `场次_output/裁剪检查/` | 生成全画面 PNG、竖条预览(文件名含真实宽如 `…598x1080`)、`塑形裁剪参数.txt`;可随时再跑覆盖 = 「重新截图」 | +| ② 核对 | 打开 `裁剪检查/` | 竖条包住小程序主体、无意外裁切、无异常大白边 | +| ③ 成片 | `soul_enhance.py`(见下模板) | 输出 **W×1080** 竖条 + 封面 + 字幕 + 加速;`--title-only` 文件名 = 标题 | +| ④ 字幕抽检 | 日志里多条「解析后无有效字幕」 | 常见为 `transcript.srt` 后半 ASR 坏段(重复/噪声);需对原片重跑 MLX Whisper 后再执行 ③ | + +**推荐目录结构**(可与下表「clips」并列,名称按你习惯;`-c` 指向实际横版切片目录即可): + +``` +xxx_output/ + highlights.json + transcript.srt + 切片/ # batch_clip 横版(或 clips/) + 裁剪检查/ # ① 输出,专放标定 PNG + txt + 成片/ # ③ 输出 + 目录索引.md +``` + +**命令模板**(在 `视频切片/脚本/` 下执行,路径换成本场): + +```bash +# ① 重新截图 / 塑形标定 +python3 analyze_feishu_ui_crop.py "/path/本场原片.mp4" --at 0.2 --save-dir "/path/xxx_output/裁剪检查" + +# ③ 竖屏成片(参数以 ① 终端打印为准;与 soul_enhance 内置默认一致时可省略 --crop-vf / --overlay-x) +python3 soul_enhance.py \ + -c "/path/xxx_output/切片" \ + -l "/path/xxx_output/highlights.json" \ + -t "/path/xxx_output/transcript.srt" \ + -o "/path/xxx_output/成片" \ + --vertical --title-only --force-burn-subs --typewriter-subs \ + --crop-vf "crop=598:1080:493:0" --overlay-x 493 +``` + +- 表中 **`crop=598:1080:493:0` / 598×1080** 为 127 场在 `0.2` 取样下的典型值;**每场以 ① 的 `CROP_VF`、`OUTPUT_SIZE`、`OVERLAY_X` 为准**。 +- 本机实测路径示例:`~/Movies/soul视频/第127场_20260318_output/`(子目录 `裁剪检查/`、`切片/`、`成片/`)。 + --- ## 四、高光与切片(30 秒~300 秒) @@ -80,10 +125,20 @@ python3 analyze_feishu_ui_crop.py "/path/to/全画面.jpg" ## 五、成片:封面 + 字幕 + 竖屏 -- **封面**:竖屏 498×1080 内**不超出界面**;**半透明质感**(背景 alpha=165);深色渐变、左上角 Soul logo;**封面显示标题 = 成片文件名 = highlights.title**(去杠、去下划线后一致,无 `:|—/_`、无序号);标题文字严格居中、多行自动换行。透明度由 `VERTICAL_COVER_ALPHA` 调节。 -- **字幕**:封面结束后先留**约 3 秒纯画面**(无字幕),再开始叠字幕;字幕**居中**在竖屏内。先尝试**单次 FFmpeg 通道**(一次 pass 完成所有字幕叠加,最快);若失败自动回退到分批模式(batch_size=40);语助词在解析阶段已由 `clean_filler_words` 去除。重新加字幕时加 `--force-burn-subs`。⚠️ 注意:当前 FFmpeg 不支持 drawtext/subtitles 滤镜,只能用 PIL 图像 overlay 方案。(脚本常量:`SUBS_START_AFTER_COVER_SEC`,默认 3.0) +- **封面**:竖条画布内**不超出界面**;**半透明质感**(背景 alpha=165);深色渐变、左上角 Soul logo;**封面显示标题 = 成片文件名 = highlights.title**(去杠、去下划线后一致,无 `:|—/_`、无序号);标题严格居中、多行自动换行。透明度由 `VERTICAL_COVER_ALPHA` 调节。 +- **字幕**:封面结束后先留**约 3 秒纯画面**(无字幕),再开始叠字幕;字幕**居中**在竖条内。先尝试**单次 FFmpeg 通道**(一次 pass 完成所有字幕叠加,最快);若失败自动回退到分批模式(batch_size=40);语助词在解析阶段已由 `clean_filler_words` 去除。重新加字幕时加 `--force-burn-subs`。⚠️ 注意:当前 FFmpeg 不支持 drawtext/subtitles 滤镜,只能用 PIL 图像 overlay 方案。(脚本常量:`SUBS_START_AFTER_COVER_SEC`,默认 3.0) - **封面标题**:高光 `title` 建议 **4~6 个汉字**;成片内封面主标题最多显示 **6 个汉字**(超长由 `soul_enhance` 自动截断,与文件名 `--title-only` 一致)。 -- **竖屏**:498×1080,crop 参数与 `参考资料/竖屏中段裁剪参数说明.md` 一致 +- **竖屏竖条**:**高固定 1080,宽 = analyze 的 OUTPUT_SIZE**,默认不压 498;细节见 `参考资料/竖屏中段裁剪参数说明.md` + +### 字幕样式与同步(soul_enhance 内置) + +| 项 | 约定 | +|----|------| +| **与封面对比** | 封面为**半透明墨绿渐变**;字幕为**暖深棕圆角条 + 琥珀色描边**,避免与主题绿混成一团 | +| **纠错** | `transcript.srt` 解析时走 `_improve_subtitle_text`(繁转简、CORRECTIONS 错词、违禁替换、去语助词);**渲染每一帧前**再走 `improve_subtitle_punctuation`,与口播稿对齐 | +| **重点词** | `KEYWORDS` 列表命中则**更大字号 + 亮金色**,长词优先匹配 | +| **逐字渐显** | 推荐成片加 **`--typewriter-subs`**:同一条字幕时间内前缀逐步加长,更贴人声节奏 | +| **音画对齐** | 默认 `SUBTITLE_DELAY_SEC` + **音轨/视频首帧 PTS 差**按比例补偿(脚本内动态计算),减轻「字比声快」 | ### ⚠️ 字幕烧录常见坑(已修复) @@ -101,14 +156,14 @@ python3 analyze_feishu_ui_crop.py "/path/to/全画面.jpg" ## 六、竖屏输出两种模式(成片内嵌) -### A. 竖条模式(默认,小程序无白边) +### A. 竖条模式(默认,保持界面真实比例) -只取横向**中间深色带**,再裁成 498 宽,适合抖音全屏铺满、不要桌面白边。 +用 `analyze_feishu_ui_crop.py`:**深色核心** → **扩边到桌面白** → **默认仅 `crop=W:1080:L:0`**(成片 W×1080,不横向拉伸)。需要固定 498 宽:加 **`--squeeze-498`** 或在 `CROP_VF` 末尾手动加 `scale=498:1080`;需要带内居中再裁 498:**`--center-in-band`**。 -| 步骤 | 滤镜 | +| 步骤 | 滤镜(127 场典型) | |------|------| -| 1 | crop=568:1080:508:0(整段深色小程序主体,不含右侧桌面白边) | -| 2 | crop=498:1080:35:0(568 内水平居中取 498) | +| 1 | `crop=598:1080:493:0`(扩边后的内容包络,输出 598×1080) | +| 2 | (可选)`,scale=498:1080:flags=lanczos` 或第二段 `crop=498:1080:…:0` | ### B. 全画面模式(`--vertical-fit-full`) @@ -118,36 +173,57 @@ python3 analyze_feishu_ui_crop.py "/path/to/全画面.jpg" `scale=w=498:h=1080:force_original_aspect_ratio=decrease,pad=498:1080:(ow-iw)/2:(oh-ih)/2:color=black` - 命令:在原有 `soul_enhance.py ... --vertical --title-only` 上增加 **`--vertical-fit-full`** -**输出**:两种模式均为 **498×1080** 竖屏文件。 +**输出**:竖条模式为 **W×1080**(W 由当场 analyze);全画面模式仍为 **498×1080**(letterbox 画布)。 --- -## 七、完整命令示例(112 场) +## 七、完整命令示例(通用 + 127 场路径) -**1. 高光**(当前模型生成 highlights.json,标题用刺激性观点,30~300 秒完整段;语助词与节奏感见 `参考资料/高光识别提示词.md`) +**0. 塑形(每场先做,见 3.2)** +```bash +cd /path/to/卡若AI/03_卡木(木)/木叶_视频内容/视频切片/脚本 +python3 analyze_feishu_ui_crop.py "/path/本场原片.mp4" --at 0.2 --save-dir "/path/xxx_output/裁剪检查" +``` -**2. 剪辑(clips)** +**1. 高光**(模型生成 highlights.json;语助词与节奏见 `参考资料/高光识别提示词.md`) + +**2. 剪辑** ```bash python3 batch_clip.py -i "原视频.mp4" -l highlights.json -o clips/ -p soul112 +# 或输出到 切片/,则成片时 -c 指向 切片/ ``` -**3. 成片(竖屏+封面+字幕+去语助词,直出到 成片/)** +**3. 成片**(竖屏条 + 封面 + 字幕 + 去语助词;vf 以 analyze 为准) ```bash python3 soul_enhance.py -c clips/ -l highlights.json -t transcript.srt -o 成片/ --vertical --title-only --force-burn-subs -# 可选:逐字字幕 + 本场全画面重算的裁剪(见 3.1) -python3 soul_enhance.py -c clips/ -l highlights.json -t transcript.srt -o 成片/ --vertical --title-only --force-burn-subs --typewriter-subs --crop-vf "crop=568:1080:508:0,crop=498:1080:35:0" +# 推荐(逐字 + 与当场 vf 一致):--typewriter-subs +python3 soul_enhance.py -c clips/ -l highlights.json -t transcript.srt -o 成片/ --vertical --title-only --force-burn-subs --typewriter-subs --crop-vf "crop=598:1080:493:0" --overlay-x 493 ``` -**前缀命名注意**:`-p soul119` 这类带场次号的前缀会产生 `soul119_01_xxx.mp4`,`soul_enhance` 会正确识别 `01` 为切片序号(取所有 `_数字_` 中最小值)。 +**127 场本机示例**(`~/Movies/soul视频/第127场_20260318_output/`): +```bash +cd ~/Documents/个人/卡若AI/03_卡木(木)/木叶_视频内容/视频切片/脚本 +python3 analyze_feishu_ui_crop.py "$HOME/Movies/soul视频/原视频/soul 派对 127场 20260318.mp4" --at 0.2 --save-dir "$HOME/Movies/soul视频/第127场_20260318_output/裁剪检查" +python3 soul_enhance.py \ + -c "$HOME/Movies/soul视频/第127场_20260318_output/切片" \ + -l "$HOME/Movies/soul视频/第127场_20260318_output/highlights.json" \ + -t "$HOME/Movies/soul视频/第127场_20260318_output/transcript.srt" \ + -o "$HOME/Movies/soul视频/第127场_20260318_output/成片" \ + --vertical --title-only --force-burn-subs --typewriter-subs \ + --crop-vf "crop=598:1080:493:0" --overlay-x 493 +``` + +**前缀命名注意**:`-p soul119` 会产生 `soul119_01_xxx.mp4`,`soul_enhance` 取 `_数字_` 中**最小值**为切片序号(如 119→01)。 输出目录结构示例: ``` xxx_output/ - clips/ # 横版切片 - 成片/ # 竖屏成片,文件名为标题.mp4 - 成片/目录索引.md highlights.json transcript.srt + 切片/ 或 clips/ # 横版 + 裁剪检查/ # analyze 输出(PNG + txt) + 成片/ # 竖屏成片,文件名为标题.mp4 + 成片/目录索引.md ``` --- @@ -156,8 +232,8 @@ xxx_output/ | 项 | 值 | |----|-----| -| 文件夹 | 仅 **clips/**、**成片/** | -| 成片尺寸 | 498×1080 竖屏 | +| 文件夹 | **切片(或 clips)** + **成片**;另 **`裁剪检查/`** 放标定素材 | +| 成片尺寸 | 竖条 **W×1080**(默认 W 由 analyze);`--vertical-fit-full` 时为 498×1080 letterbox | | 成片文件名 | 纯标题(无 01、无 _enhanced) | | 单段时长 | 30~300 秒 | | 高光/语助词 | 见 `参考资料/高光识别提示词.md` | @@ -214,7 +290,7 @@ python3 visual_enhance.py -i "soul_enhanced.mp4" -o "成片/标题.mp4" --scenes | **音视频同步生成** | LTX-2 **A2V**、音视频同步生成 | 配音/旁白 → 对应画面,补全缺失画面 | **能力与集成细节**:见 `参考资料/LTX_能力与集成说明.md`(含 Retake、Video extension、多关键帧、Prompt 增强、API/本地/Desktop 接入方式)。 -**流程约定**:凡 LTX 生成的片段,统一按成片规范(竖屏 498×1080、封面、字幕)经 soul_enhance 输出,与录播切片一致。 +**流程约定**:凡 LTX 生成的片段,统一按成片规范(竖条或 letterbox、封面、字幕)经 soul_enhance 输出,与录播切片一致。 --- diff --git a/03_卡木(木)/木叶_视频内容/视频切片/参考资料/竖屏中段裁剪参数说明.md b/03_卡木(木)/木叶_视频内容/视频切片/参考资料/竖屏中段裁剪参数说明.md index acd22831..d9487d64 100644 --- a/03_卡木(木)/木叶_视频内容/视频切片/参考资料/竖屏中段裁剪参数说明.md +++ b/03_卡木(木)/木叶_视频内容/视频切片/参考资料/竖屏中段裁剪参数说明.md @@ -1,32 +1,53 @@ # Soul 竖屏中段裁剪参数说明 -> 与 **视频切片 / Soul竖屏切片** Skill 一致。**参数来源**:以 **全画面**(1920×1080)截帧做列亮度分析(`脚本/analyze_feishu_ui_crop.py`),取最长深色列区间为第一步 `crop` 宽度,再居中裁 498;下列数值为当前飞书+小程序布局下的默认结果。 +> 与 **视频切片 / Soul竖屏切片** Skill 一致。**参数来源**:以全画面(通常 1920×1080)截帧做列亮度分析(`脚本/analyze_feishu_ui_crop.py`),先找深色核心再扩到桌面白边,得到包络宽度 **W**;**默认仅 `crop=W:1080:L:0`,不强制 498 宽**,避免界面横向拉伸。 ## 源与输出 -- **源视频**:横版 1920×1080(16:9,soul_enhance 输出) -- **需求**:保留画面中间竖条(手机内容区),去左右白边 -- **输出**:**498×1080** 竖屏 +- **源视频**:横版 1920×1080(16:9,batch_clip / soul_enhance 前后均可) +- **目标**:保留中间「小程序+飞书内容」竖条,去左右大白边 +- **输出(竖条模式)**:**W×1080**,W 由当场 analyze 打印(常见约 560~750,随布局而变) +- **输出(可选 squeeze)**:在 `CROP_VF` 末尾加 `,scale=498:1080:flags=lanczos` 得 **498×1080**(略横向压缩,旧抖音兼容) -## 当前固定参数(2026-03-20 起:白边修正) +## 当前默认(保持真实比例) -飞书横屏录屏里,小程序**深色区域**右缘外常为桌面白底。应用 **整段深色宽度** 再居中裁 498,避免右侧露白。 +1. **深色核心**(列平滑度阈值)定大致区域; +2. **向左右扩**到列均值/平滑达到「桌面大白」阈值; +3. **默认**只裁 `crop=W:1080:L:0`,成片宽高 = **W×1080**。 -| 步骤 | 滤镜 | 说明 | -|------|------|------| -| 1 | crop=568:1080:508:0 | 取深色主体竖条(宽约 568,起点 x=508,止于约 1076,不含右侧白区) | -| 2 | crop=498:1080:35:0 | 在 568 内水平居中取 498 | -| 输出 | 498×1080 | 完整界面入画、左右无桌面白边 | +| 模式 | 滤镜示例(127 场 20% 标定,数值仅示意) | 说明 | +|------|----------------|------| +| **默认(推荐)** | `crop=598:1080:493:0` | 包络宽约 598,OVERLAY_X=493,不 scale | +| 旧·强制 498 宽 | 上式 + `,scale=498:1080:flags=lanczos` | analyze 的 `--squeeze-498` | +| 带内居中再裁 498 | `crop=W:1080:L:0,crop=498:1080:inner:0` | `--center-in-band`(W≥498) | +| 两段居中裁(旧) | `crop=568:1080:508:0,crop=498:1080:35:0` | `--strict-core` + `--center-in-band` 等组合 | -**校验**:对原片按时长 **20%** 截一帧全画面,用脚本 `脚本/analyze_feishu_ui_crop.py` 可对任意场次重算 `crop`(布局变化时)。 +## 每场必做:先 analyze 再成片 -**一条命令:** +流程与 **`Soul竖屏切片_SKILL.md` §3.2** 一致:① `裁剪检查/` 出图 → ② 肉眼核对 → ③ `soul_enhance` 进 `成片/`。 + +1. 在原片 **20% 时长** 处取一帧(脚本 `--at 0.2` 会自动 `ffmpeg` 抽帧)。 +2. 运行: ```bash -ffmpeg -y -i "输入_enhanced.mp4" -vf "crop=568:1080:508:0,crop=498:1080:35:0" -c:a copy "输出_竖屏中段.mp4" +cd 脚本 +python3 analyze_feishu_ui_crop.py "/path/原片.mp4" --at 0.2 --save-dir "/path/场次_output/裁剪检查" ``` -**批量:** 使用 `脚本/soul_vertical_crop.py --dir clips_enhanced目录`。加 `--title-only` 时输出文件名为纯标题(无序号、无「竖屏中段」)。 +3. **先看预览 PNG 与 txt**,确认竖条包住主体、无意外白边。 +4. 把终端打印的 `CROP_VF`、`OVERLAY_X`(及 `OUTPUT_SIZE`)用于: + +```bash +python3 soul_enhance.py ... --vertical --title-only --crop-vf 'crop=...' --overlay-x ... +``` + +**一条 ffmpeg 命令(与默认 analyze 一致时):** + +```bash +ffmpeg -y -i "输入.mp4" -vf "crop=598:1080:493:0" -c:a copy "输出_竖屏条.mp4" +``` + +**批量**:`脚本/soul_vertical_crop.py --dir clips目录`(修改脚本顶部 `CROP_VF` 与当场一致)。加 `--title-only` 时输出文件名为纯标题。 ## 若源为竖版 1080×1920 diff --git a/03_卡木(木)/木叶_视频内容/视频切片/脚本/analyze_feishu_ui_crop.py b/03_卡木(木)/木叶_视频内容/视频切片/脚本/analyze_feishu_ui_crop.py index 6d74963f..44353229 100755 --- a/03_卡木(木)/木叶_视频内容/视频切片/脚本/analyze_feishu_ui_crop.py +++ b/03_卡木(木)/木叶_视频内容/视频切片/脚本/analyze_feishu_ui_crop.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """ -从飞书/Soul 录屏的一帧全画面(1920×1080)估计「深色小程序主体」左右边界, -输出两段 crop + overlay_x,保证界面整段入画、尽量不夹右侧白底。 +从飞书/Soul 录屏的一帧全画面(1920×1080)估计「深色核心」后**扩边到桌面白**, +默认输出 **仅 crop=包络宽×1080**,保持截图真实横纵比、不横向拉伸;可选 `--squeeze-498` 压宽到 498 或 `--center-in-band` 带内居中裁 498。 用法: python3 analyze_feishu_ui_crop.py /path/to/frame.jpg python3 analyze_feishu_ui_crop.py /path/to/video.mp4 --at 0.2 + python3 analyze_feishu_ui_crop.py /path/to/video.mp4 --at 0.2 --save-dir ./裁剪检查 + → 写入「全画面取样」与「竖条塑形预览 498×1080」,便于对照后再跑 soul_enhance --crop-vf """ import argparse @@ -66,6 +68,27 @@ def main(): ap = argparse.ArgumentParser() ap.add_argument("input", type=Path, help="全画面截图 jpg/png 或视频 mp4") ap.add_argument("--at", type=float, default=None, help="视频取样比例 0~1,默认 0.2") + ap.add_argument( + "--save-dir", + type=Path, + default=None, + help="可选:保存全画面标定帧 PNG、竖条 498×1080 预览 PNG、塑形参数 txt", + ) + ap.add_argument( + "--strict-core", + action="store_true", + help="仅用最深色核心宽度(旧逻辑,可能偏窄);默认会向左右扩到桌面白边", + ) + ap.add_argument( + "--center-in-band", + action="store_true", + help="扩边后在带内居中再裁成 498 宽(不拉伸;带宽不足则退化为 scale 到 498)", + ) + ap.add_argument( + "--squeeze-498", + action="store_true", + help="扩边后横向压到 498 宽(会拉伸变形,仅兼容旧抖音尺寸)", + ) args = ap.parse_args() arr = load_frame(args.input, args.at) @@ -78,6 +101,7 @@ def main(): kernel = np.ones(win) / win smooth = np.convolve(np.pad(col_mean, (pad, pad), mode="edge"), kernel, mode="valid") + # 第一步:找最深色连续区作为「核心」,避免误选整屏平均 dark = smooth < 105 best = (0, 0) i = 0 @@ -96,25 +120,119 @@ def main(): print("未找到足够宽的深色带,请换一帧或检查分辨率", file=sys.stderr) sys.exit(1) - # 右缘:深色带结束后出现的持续高亮(白底) + # 右缘参考:深色带之后持续高亮(白底)从哪列起 right = R0 for x in range(R0, min(R0 + 500, w)): if smooth[x] > 195 and col_mean[x] > 200 and x + 5 < w and smooth[x : x + 5].min() > 185: right = x break - # 严格包络:只用最长深色块 [L0,R0),避免把右侧灰白过渡算进画面(用户要求无白边) - L = max(0, L0) - W_strict = R0 - L0 - if W_strict < 498: - print(f"深色带宽度 {W_strict} < 498,需 scale 或换源", file=sys.stderr) + if args.strict_core: + L = max(0, L0) + W_band = R0 - L0 + else: + # 第二步:从核心向左右扩到「桌面大白」边界(阈值略放宽,避免把浅灰边栏判成「已到边」而过窄) + white_mean = 248.0 + white_smooth = 228.0 + + def col_is_desktop_white(x: int) -> bool: + if x < 0 or x >= w: + return True + return col_mean[x] >= white_mean and smooth[x] >= white_smooth + + L = L0 + while L > 0 and not col_is_desktop_white(L - 1): + L -= 1 + + R = R0 + while R < w and not col_is_desktop_white(R): + R += 1 + + W_band = R - L + if W_band < 200: + print( + f"扩边后宽度 {W_band} 过窄,回退为深色核心 [{L0},{R0})", + file=sys.stderr, + ) + L, W_band = max(0, L0), R0 - L0 + + if W_band < 200: + print(f"可用宽度 {W_band} 过窄,请换一帧", file=sys.stderr) sys.exit(1) - inner = (W_strict - 498) // 2 - ox = L + inner - vf = f"crop={W_strict}:1080:{L}:0,crop=498:1080:{inner}:0" - print(f"# {w}x{h} 最长深色列区间 [{L0},{R0}) 宽={W_strict};白底高亮约从 x>={right} 起") + + if args.squeeze_498 and args.center_in_band: + print("同时指定 --squeeze-498 与 --center-in-band 时以 --center-in-band 为准", file=sys.stderr) + + if args.center_in_band: + if W_band >= 498: + inner = (W_band - 498) // 2 + ox = L + inner + vf = f"crop={W_band}:1080:{L}:0,crop=498:1080:{inner}:0" + mode = "center_crop_498" + else: + ox = L + vf = f"crop={W_band}:1080:{L}:0,scale=498:1080:flags=lanczos" + mode = "center_band_fallback_scale498" + print( + f"包络宽 {W_band}<498,center-in-band 退化为横向 scale 至 498", + file=sys.stderr, + ) + elif args.squeeze_498: + ox = L + vf = f"crop={W_band}:1080:{L}:0,scale=498:1080:flags=lanczos" + mode = "squeeze_to_498" + else: + # 默认:保持包络真实像素比,成片宽 = W_band(常见 560~750,随场次而变) + ox = L + vf = f"crop={W_band}:1080:{L}:0" + mode = "native_aspect_strip" + + geo = "strict_core" if args.strict_core else f"expand_edge" + if ",crop=498:1080" in vf.replace(" ", "") or "scale=498:1080" in vf.replace(" ", ""): + out_px = 498 + else: + out_px = W_band + + print( + f"# {w}x{h} 深色核心 [{L0},{R0}) 宽={R0-L0};{geo}+{mode};包络 [{L},{L+W_band}) 宽={W_band};成片约 {out_px}x1080;白底约 x>={right}" + ) print(f"CROP_VF={vf!r}") print(f"OVERLAY_X={ox}") + print(f"OUTPUT_SIZE={out_px}x1080") + + if args.save_dir: + args.save_dir = args.save_dir.resolve() + args.save_dir.mkdir(parents=True, exist_ok=True) + ratio = float(args.at) if args.at is not None else 0.2 + stem = args.input.stem.replace(" ", "_")[:40] + u8 = np.clip(arr, 0, 255).astype(np.uint8) + full_path = args.save_dir / f"{stem}_全画面_{ratio:.0%}.png" + Image.fromarray(u8).save(full_path, optimize=True) + strip = u8[:, L : L + W_band, :] + if args.center_in_band and W_band >= 498: + inner_sv = (W_band - 498) // 2 + strip_img = Image.fromarray(strip[:, inner_sv : inner_sv + 498, :]) + strip_name = f"{stem}_竖条预览_498x1080.png" + elif (args.center_in_band and W_band < 498) or args.squeeze_498: + strip_img = Image.fromarray(strip).resize((498, 1080), Image.LANCZOS) + strip_name = f"{stem}_竖条预览_498x1080.png" + else: + strip_img = Image.fromarray(strip) + strip_name = f"{stem}_竖条塑形预览_{W_band}x1080.png" + strip_path = args.save_dir / strip_name + strip_img.save(strip_path, optimize=True) + txt_path = args.save_dir / f"{stem}_塑形裁剪参数.txt" + txt_path.write_text( + f"# 取样: {args.input.name} ratio={ratio}\n" + f"# 成片宽高(竖条)\n" + f"CROP_VF={vf!r}\nOVERLAY_X={ox}\nOUTPUT_SIZE={out_px}x1080\n\n" + f"soul_enhance 示例:\n" + f' --crop-vf "{vf}" --overlay-x {ox}\n', + encoding="utf-8", + ) + print(f"SAVED_FULL={full_path}") + print(f"SAVED_STRIP={strip_path}") + print(f"SAVED_TXT={txt_path}") if __name__ == "__main__": diff --git a/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_enhance.py b/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_enhance.py index 62e09c17..3a407251 100644 --- a/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_enhance.py +++ b/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_enhance.py @@ -57,12 +57,12 @@ SILENCE_THRESHOLD = -40 # 静音阈值(dB) SILENCE_MIN_DURATION = 0.5 # 最短静音时长(秒) # Soul 竖屏裁剪(与 soul_vertical_crop 一致,成片直出用) -# 飞书录屏典型布局:深色小程序主体约在 x∈[508,1076],右缘外为桌面白底。 -# 旧参数 483+608=1091 会吃进右侧白边;现改为「整段深色界面入画 + 居中取 498」。 -CROP_VF = "crop=568:1080:508:0,crop=498:1080:35:0" -# 竖屏成片时封面/字幕用此尺寸,叠在横版上的 x 位置(与 crop 后保留区域左缘一致) -VERTICAL_W, VERTICAL_H = 498, 1080 -OVERLAY_X = 543 # 508+35,与历史 483+60 对齐,避免封面/字幕错位 +# 默认与 analyze_feishu_ui_crop.py(20% 帧、扩边到桌面白 + 横向 scale)典型输出一致;他场次请先跑分析再 --crop-vf。 +# 默认与 analyze_feishu_ui_crop 一致:仅 crop 包络宽×1080,不横向压扁;旧 498 见 --crop-vf 两段裁或 scale。 +CROP_VF = "crop=598:1080:493:0" +VERTICAL_H = 1080 +VERTICAL_W_LEGACY = 498 # 两段裁 / squeeze-498 时输出宽 +OVERLAY_X = 493 # 竖屏「全画面入画」:不裁中间竖条;整幅横版等比缩放入 498×1080,上下黑边(letterbox) VERTICAL_FIT_FULL_VF = ( @@ -72,16 +72,43 @@ VERTICAL_FIT_FULL_VF = ( def _overlay_x_from_crop_vf(crop_vf: str): - """从两段 crop 解析字幕/封面叠在横版上的 x:crop=W:1080:X:0,crop=498:1080:Y:0 → X+Y""" - m = re.match( - r"crop=\d+:1080:(\d+):0,crop=498:1080:(\d+):0", - (crop_vf or "").strip().replace(" ", ""), - ) + """从滤镜链解析字幕/封面叠在横版上的 x。 + - 两段 crop:crop=W:1080:X:0,crop=498:1080:Y:0 → X+Y + - crop + scale=498:crop=W:1080:L:0,scale=498:1080... → L + - 仅 crop 包络:crop=W:1080:L:0 → L""" + s = (crop_vf or "").strip().replace(" ", "") + m = re.match(r"crop=\d+:1080:(\d+):0,crop=498:1080:(\d+):0", s) if m: return int(m.group(1)) + int(m.group(2)) + m = re.match(r"crop=\d+:1080:(\d+):0,scale=498:1080", s) + if m: + return int(m.group(1)) + m = re.match(r"^crop=\d+:1080:(\d+):0$", s) + if m: + return int(m.group(1)) return None +def vertical_out_dimensions_from_vf(crop_vf: str) -> tuple[int, int]: + """竖条成片像素尺寸:原生包络宽×1080,或强制 498 宽。""" + s = (crop_vf or "").strip().replace(" ", "") + if not s: + return VERTICAL_W_LEGACY, VERTICAL_H + if re.match(r"^crop=\d+:1080:\d+:0,crop=498:1080:\d+:0$", s): + return VERTICAL_W_LEGACY, VERTICAL_H + if re.search(r",scale=498:1080", s): + return VERTICAL_W_LEGACY, VERTICAL_H + m = re.match(r"^crop=(\d+):1080:\d+:0$", s) + if m: + return int(m.group(1)), VERTICAL_H + return VERTICAL_W_LEGACY, VERTICAL_H + + +def _is_vertical_strip_canvas(w: int, h: int) -> bool: + """竖条成片画布:固定高度 1080、宽度小于全幅横版(封面/字幕走竖条样式)。""" + return h == VERTICAL_H and w < 1600 + + def build_typewriter_subtitle_images( subtitles, temp_dir, @@ -99,7 +126,7 @@ def build_typewriter_subtitle_images( sub_images = [] img_idx = 0 for sub in subtitles: - safe_text = improve_subtitle_punctuation(sub["text"]) + safe_text = improve_subtitle_punctuation(_improve_subtitle_text(sub["text"])) if not safe_text or not safe_text.strip(): continue s, e = float(sub["start"]), float(sub["end"]) @@ -256,6 +283,10 @@ KEYWORDS = [ '电商', '创业', '项目', '收益', '流量', '引流', '抖音', 'Soul', '微信', '美团', '方法', '技巧', '干货', '核心', '关键', '重点', '赚钱', '收入', '利润', + # 127 场及同类话题常见词 + '消耗', '算力', '工资', '月薪', '两万', '2万', '面试', '三面', '实操', '学历', + '推流', '三板斧', '后端', '前端', '暴利', '缺口', '保镖', '辅助', '两头扛', + '剪辑', '成片', '模型', '规则', '组织', ] # 字体优先级(封面用更好看的字体) @@ -296,18 +327,22 @@ STYLE = { 'subtitle': { 'font_size': 44, 'color': (255, 255, 255), - 'outline_color': (25, 25, 25), + 'outline_color': (20, 12, 8), 'outline_width': 4, - 'keyword_color': (255, 200, 50), # 亮金黄 - 'keyword_outline': (80, 50, 0), # 深黄描边 - 'keyword_size_add': 4, # 关键词字号+4 - 'bg_color': (15, 15, 35, 200), + 'keyword_color': (255, 232, 120), # 亮金,与条底色对比强 + 'keyword_outline': (90, 55, 0), + 'keyword_size_add': 6, # 关键词更大一号,突出重点 + # 与封面「墨绿半透明」区分:暖深棕底 + 琥珀描边,一眼可辨是字幕条 + 'bg_color': (34, 20, 16, 238), + 'border_color': (255, 186, 90, 255), + 'border_width': 2, 'margin_bottom': 70, } } # 字幕与语音同步的全局延迟补偿(秒);封面后留白再叠字幕;封面标题汉字上限(须在本文件先于 _limit_cover_title_cjk 定义) -SUBTITLE_DELAY_SEC = 2.0 +# 略抬高默认值,配合下方音轨/视频 PTS 差比例,减少「字先于人」 +SUBTITLE_DELAY_SEC = 2.15 SUBS_START_AFTER_COVER_SEC = 3.0 COVER_TITLE_MAX_CJK = 6 @@ -799,14 +834,14 @@ def _strip_cover_number_prefix(text): def create_cover_image(hook_text, width, height, output_path, video_path=None): - """创建封面贴片。竖屏 498x1080 时:高级渐变背景、文字严格在界面内居中不超出、左上角 Soul logo;封面不显示 123 等序号。""" + """创建封面贴片。竖条(高 1080、宽由塑形)时:半透明渐变、文字在条内居中、左上角 Soul logo;不显示切片序号前缀。""" hook_text = _to_simplified(str(hook_text or "").strip()) hook_text = _strip_cover_number_prefix(hook_text) if not hook_text: hook_text = "精彩切片" style = STYLE['cover'] hook_style = STYLE['hook'] - is_vertical = (width, height) == (VERTICAL_W, VERTICAL_H) + is_vertical = _is_vertical_strip_canvas(width, height) if is_vertical: # 竖屏成片:半透明质感封面,渐变背景带 alpha,透出底层画面 @@ -858,7 +893,7 @@ def create_cover_image(hook_text, width, height, output_path, video_path=None): # 标题文字:竖屏时严格限制在 padding 内,多行居中,绝不超出界面 if is_vertical: - max_text_width = width - 2 * VERTICAL_COVER_PADDING # 498 - 88 = 410 + max_text_width = width - 2 * VERTICAL_COVER_PADDING cover_font_size = 48 font = get_cover_font(cover_font_size) lines = [] @@ -931,14 +966,20 @@ def create_cover_image(hook_text, width, height, output_path, video_path=None): # ============ 字幕图片生成 ============ def create_subtitle_image(text, width, height, output_path): - """创建字幕图片(关键词加粗加大突出)。498 竖条时居中;全幅横版时偏下居中(为 --vertical-fit-full)。""" + """创建字幕图片(纠错+关键词加大加亮)。竖条画布时居中;全幅横版时偏下居中(为 --vertical-fit-full)。""" style = STYLE['subtitle'] - + # 成片前再跑一遍纠错/标点,与 parse 阶段互补(逐字帧也走本函数) + text = improve_subtitle_punctuation(_improve_subtitle_text(text)) + if not (text or "").strip(): + img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + img.save(output_path, 'PNG') + return output_path + img = Image.new('RGBA', (width, height), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) base_size = style['font_size'] - if (width, height) == (VERTICAL_W, VERTICAL_H): + if _is_vertical_strip_canvas(width, height): base_size = min(base_size, 38) elif height == 1080 and width >= 1280: # 1920×1080 全画面叠字:字号略大,条带靠下,避免挡脸 @@ -954,7 +995,7 @@ def create_subtitle_image(text, width, height, output_path): kw_font = get_font(FONT_HEAVY, kw_size) base_x = (width - text_w) // 2 - if (width, height) == (VERTICAL_W, VERTICAL_H): + if _is_vertical_strip_canvas(width, height): pad = 24 base_x = max(pad, min(width - pad - text_w, base_x)) base_y = (height - text_h) // 2 @@ -974,10 +1015,17 @@ def create_subtitle_image(text, width, height, output_path): min(height, base_y + text_h + padding) ] - # 绘制圆角背景 + # 绘制圆角背景(暖色底 + 与封面绿系区分的琥珀描边) bg_layer = Image.new('RGBA', (width, height), (0, 0, 0, 0)) bg_draw = ImageDraw.Draw(bg_layer) - bg_draw.rounded_rectangle(bg_rect, radius=10, fill=style['bg_color']) + r = 14 + fill = style['bg_color'] + outline = style.get('border_color') + ow = int(style.get('border_width', 0) or 0) + if outline and ow > 0: + bg_draw.rounded_rectangle(bg_rect, radius=r, fill=fill, outline=outline[:3], width=ow) + else: + bg_draw.rounded_rectangle(bg_rect, radius=r, fill=fill) img = Image.alpha_composite(img, bg_layer) draw = ImageDraw.Draw(img) @@ -1117,9 +1165,8 @@ def enhance_clip(clip_path, output_path, highlight_info, temp_dir, transcript_pa force_burn_subs=False, skip_subs=False, vertical=False, crop_vf=None, overlay_x=None, typewriter_subs=False, vertical_fit_full=False): - """增强单个切片。vertical=True 时最后输出 498×1080。 - vertical_fit_full:不裁中间竖条;整幅画面等比缩放入 498×1080 + 上下黑边,前后内容都可见。 - 否则沿用 crop 竖条(全画面标定深色带)。 + """增强单个切片。vertical=True 时输出竖条,宽由 --crop-vf 决定(原生包络常见 560~750×1080;旧 498 为两段裁或 scale)。 + vertical_fit_full:整幅 16:9 缩放入 498×1080 + 上下黑边。 """ print(f" 输入: {os.path.basename(clip_path)}", flush=True) @@ -1143,17 +1190,19 @@ def enhance_clip(clip_path, output_path, highlight_info, temp_dir, transcript_pa cover_duration = STYLE['cover']['duration'] subtitle_overlay_start = cover_duration + SUBS_START_AFTER_COVER_SEC - # 竖屏:默认封面/字幕按 498×1080 叠在竖条上;全画面模式按原分辨率全屏叠加再整体缩放 + # 竖屏:封面/字幕按解析出的竖条宽×1080 叠在横版上;全画面模式按原分辨率全屏叠加再整体缩放 if vertical and vertical_fit_full: out_w, out_h = width, height vf_use = "" overlay_pos = "0:0" elif vertical: - out_w, out_h = VERTICAL_W, VERTICAL_H vf_use = (crop_vf or CROP_VF).strip() + out_w, out_h = vertical_out_dimensions_from_vf(vf_use) ox = overlay_x if ox is None and crop_vf: ox = _overlay_x_from_crop_vf(crop_vf) + if ox is None: + ox = _overlay_x_from_crop_vf(vf_use) if ox is None: ox = OVERLAY_X overlay_pos = f"{int(ox)}:0" @@ -1214,13 +1263,11 @@ def enhance_clip(clip_path, output_path, highlight_info, temp_dir, transcript_pa 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 偏移 + # 视频帧 PTS 与音频帧 PTS 的差值揭示 input 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 + # 按比例推迟字幕,更贴人声;夹紧区间避免过激或失控 + raw_delay = SUBTITLE_DELAY_SEC + offset * 0.72 + actual_delay = max(1.65, min(4.0, raw_delay)) except Exception: pass @@ -1245,8 +1292,7 @@ def enhance_clip(clip_path, output_path, highlight_info, temp_dir, transcript_pa else: for i, sub in enumerate(subtitles): img_path = os.path.join(temp_dir, f'sub_{i:04d}.png') - safe_text = improve_subtitle_punctuation(sub['text']) - create_subtitle_image(safe_text, out_w, out_h, img_path) + create_subtitle_image(sub['text'], out_w, out_h, img_path) sub_images.append({'path': img_path, 'start': sub['start'], 'end': sub['end']}) if sub_images: print(f" ✓ 字幕图片 ({len(sub_images)}张)", flush=True) @@ -1258,7 +1304,7 @@ def enhance_clip(clip_path, output_path, highlight_info, temp_dir, transcript_pa # 5. 构建FFmpeg命令 current_video = clip_path - # 5.1 添加封面(封面图 -loop 1 保证前 3 秒完整显示;竖屏时叠在 x=543) + # 5.1 添加封面(封面图 -loop 1 保证前若干秒完整显示;竖条时叠在 overlay_pos) print(f" [2/5] 封面烧录中…", flush=True) cover_output = os.path.join(temp_dir, 'with_cover.mp4') cmd = [ @@ -1284,7 +1330,7 @@ def enhance_clip(clip_path, output_path, highlight_info, temp_dir, transcript_pa if sub_images: print(f" [3/5] 字幕烧录中({len(sub_images)} 条,concat+overlay 单次 pass)…", flush=True) - # 创建透明空白帧(RGBA 498x1080,所有像素透明) + # 创建透明空白帧(与竖条/全画面 out_w×out_h 一致) blank_path = os.path.join(temp_dir, 'sub_blank.png') if not os.path.exists(blank_path): blank = Image.new('RGBA', (out_w, out_h), (0, 0, 0, 0)) @@ -1369,8 +1415,11 @@ def enhance_clip(clip_path, output_path, highlight_info, temp_dir, transcript_pa if result.stderr: print(f" {str(result.stderr)[:300]}", file=sys.stderr) - # 5.4 输出:竖屏 498×1080(竖条裁剪 或 全画面 letterbox) - print(f" [5/5] 竖屏输出(498×1080)…", flush=True) + # 5.4 输出:竖条(宽由 vf)或全画面 letterbox + if vertical and not vertical_fit_full: + print(f" [5/5] 竖屏输出({out_w}×{out_h})…", flush=True) + else: + print(f" [5/5] 竖屏输出…", flush=True) if vertical and vertical_fit_full: r = subprocess.run([ 'ffmpeg', '-y', '-i', current_video, @@ -1411,14 +1460,18 @@ def main(): parser.add_argument("--highlights", "-l", help="highlights.json 路径") parser.add_argument("--transcript", "-t", help="transcript.srt 路径") parser.add_argument("--output", "-o", help="输出目录(成片时填 成片 文件夹路径)") - parser.add_argument("--vertical", action="store_true", help="成片直出竖屏 498x1080,与封面+字幕一起输出到 -o 目录") + parser.add_argument( + "--vertical", + action="store_true", + help="成片直出竖条(高1080,宽由 analyze / --crop-vf 决定,默认保持界面真实比例)", + ) parser.add_argument("--title-only", action="store_true", help="输出文件名为纯标题(无序号、无_enhanced),与 --vertical 搭配用于成片") parser.add_argument("--skip-subs", action="store_true", help="跳过字幕烧录(原片已有字幕时用)") parser.add_argument("--force-burn-subs", action="store_true", help="强制烧录字幕(忽略检测)") parser.add_argument( "--crop-vf", default="", - help="竖屏时覆盖裁剪链,如 crop=560:1080:465:0,crop=498:1080:31:0(先对原片 20%% 时长处截帧对齐小程序黑框)", + help="竖屏时覆盖裁剪链;默认仅单段 crop 保持真实比例;旧抖音可两段裁或加 scale=498(先 analyze_feishu_ui_crop 再粘贴)", ) parser.add_argument( "--overlay-x", @@ -1465,7 +1518,7 @@ def main(): vfit = getattr(args, "vertical_fit_full", False) print( f"功能: 封面+字幕+加速10%+去语气词" - + ("+竖屏498x1080" if vertical else "") + + ("+竖屏条(高1080宽随vf)" if vertical else "") + ("+全画面letterbox(不裁竖条)" if vertical and vfit else "") + ("+逐字字幕" if typewriter else "") ) @@ -1540,7 +1593,7 @@ def generate_index(highlights, output_dir): with open(index_path, 'w', encoding='utf-8') as f: f.write("# Soul派对 - 成片目录\n\n") - f.write("**优化**: 封面+字幕+加速10%+去语气词(成片含竖屏时已裁为498×1080)\n\n") + f.write("**优化**: 封面+字幕+加速10%+去语气词(竖屏条高度1080,宽随塑形标定,常见非498)\n\n") f.write("## 切片列表\n\n") f.write("| 序号 | 标题 | Hook | CTA |\n") f.write("|------|------|------|-----|\n") diff --git a/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_vertical_crop.py b/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_vertical_crop.py index 9e4560c1..16ce47d3 100644 --- a/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_vertical_crop.py +++ b/03_卡木(木)/木叶_视频内容/视频切片/脚本/soul_vertical_crop.py @@ -2,8 +2,8 @@ # -*- coding: utf-8 -*- """ Soul 竖屏中段裁剪(批量) -横版 1920×1080 → 竖屏 498×1080,去左右白边。 -参数与 SKILL「Soul 竖屏成片」一致,以后剪辑 Soul 视频统一用此脚本。 +横版 1920×1080 → 竖条(高 1080,宽 = analyze 包络宽,默认不横向压扁)。 +每场先用 analyze_feishu_ui_crop 得到 CROP_VF 后,可改本文件 CROP_VF 或后续加 CLI。 """ import argparse import re @@ -11,8 +11,9 @@ import subprocess import sys from pathlib import Path -# 固定参数(与 SKILL 一致) -CROP_VF = "crop=568:1080:508:0,crop=498:1080:35:0" +# 与 soul_enhance 默认一致;他场次请用 analyze_feishu_ui_crop.py 打印的 CROP_VF 覆盖 +# 需旧版 498 宽:在末尾加 ,scale=498:1080:flags=lanczos +CROP_VF = "crop=598:1080:493:0" OUT_SUFFIX = "_竖屏中段" diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index 130fe547..e25d0b4f 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -56,8 +56,9 @@ **第三步:至少两轮验证** - 结果与用户目标匹配→交付。不匹配→回溯→搜索→再思考→再执行→再验证。 -**第四步:强制复盘** -- 所有回复用复盘形式(🎯📌💡📝▶ 五块),带日期+时间(YYYY-MM-DD HH:mm),目标行≤30字。格式见 `运营中枢/参考资料/卡若复盘格式_固定规则.md`。 +**第四步:Mongo 对话留存 + 强制复盘** +- **先**完成 Mongo 留存闭环(将本轮 Cursor 对话写入 `karuo_site`,不重复键):执行 `python3 01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/realtime_chat_sync.py`;细则与顺序见 `运营中枢/参考资料/卡若AI_Mongo对话留存闭环.md` 与 `.cursor/rules/karuo-ai.mdc`。 +- **再**用复盘形式收尾(🎯📌💡📝▶ 五块),带日期+时间(YYYY-MM-DD HH:mm),目标行≤30字。格式见 `运营中枢/参考资料/卡若复盘格式_固定规则.md`。 ## 六、记忆 @@ -80,6 +81,7 @@ | Gitea 同步 | `bash 金仓_存储备份/Gitea管理/脚本/自动同步.sh` | | Pipeline | 读 `运营中枢/参考资料/Pipeline执行清单.md` | | 对外输出 | → `/Users/karuo/Documents/卡若Ai的文件夹/` 对应子目录;图片→`图片/`+登记索引 | +| Mongo 对话留存 | 每条对话结束前执行 `01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/realtime_chat_sync.py`;闭环见 `运营中枢/参考资料/卡若AI_Mongo对话留存闭环.md` | ## 八、全局规则 diff --git a/SKILL_REGISTRY.md b/SKILL_REGISTRY.md index fa67eda0..83d9a728 100644 --- a/SKILL_REGISTRY.md +++ b/SKILL_REGISTRY.md @@ -74,7 +74,7 @@ | G17 | 数据库管理(安全) | 金盾 | 数据库、备份数据 | `01_卡资(金)/金盾_数据安全/数据库管理/SKILL.md` | 数据库安全与备份 | | G18 | 微信管理(安全) | 金盾 | 微信数据库解析 | `01_卡资(金)/金盾_数据安全/微信管理/SKILL.md` | 微信数据库解密与分析 | | G19 | 存客宝副本管理 | 金盾 | 存客宝副本、cunkebao_副本、存客宝开发文档、副本代码管理 | 副本项目内 `cunkebao_副本/.cursor/skills/存客宝副本管理/SKILL.md`(以副本为主,路径见下) | 存客宝副本代码与开发文档管理,Skill 已迁入副本项目内 | -| G22 | **聊天记录管理** | 金仓 | **聊天记录、对话存储、聊天归档、聊天导出、聊天导入、清理聊天、对话查询、历史对话、state.vscdb、bubbleId、cursor聊天、对话迁移、聊天分类** | `01_卡资(金)/金仓_存储备份/聊天记录管理/SKILL.md` | Cursor 聊天记录→MongoDB 分类存储+查询+导入导出+安全清理 | +| G22 | **聊天记录管理** | 金仓 | **聊天记录、对话存储、聊天归档、聊天导出、聊天导入、清理聊天、对话查询、历史对话、state.vscdb、bubbleId、cursor聊天、对话迁移、聊天分类、Mongo留存闭环** | `01_卡资(金)/金仓_存储备份/聊天记录管理/SKILL.md` | Cursor→MongoDB upsert+唯一索引+项目分类汇总;闭环见 `运营中枢/参考资料/卡若AI_Mongo对话留存闭环.md` | ## 水组 · 卡人(信息流程调度) diff --git a/运营中枢/参考资料/卡若AI_Mongo对话留存闭环.md b/运营中枢/参考资料/卡若AI_Mongo对话留存闭环.md new file mode 100644 index 00000000..c7cb757f --- /dev/null +++ b/运营中枢/参考资料/卡若AI_Mongo对话留存闭环.md @@ -0,0 +1,58 @@ +# 卡若AI · Mongo 对话留存闭环(默认格式) + +> **用途**:统一「Cursor 对话 → `karuo_site` → 可查可统计」的**固定顺序**,减少漏同步、重复数据与分类漂移。 +> **库**:唯一 MongoDB **27017**,库名 **`karuo_site`**;集合 **`对话记录`**、**`消息内容`**、**`项目分类`**(由脚本聚合刷新)。 + +--- + +## 一、每条对话结束前(写出复盘块之前) + +按顺序执行(**由 Cursor 规则强制执行;Agent 直接跑命令,不询问**): + +1. **实时同步当前 Cursor 会话**(全量 upsert 本条对话的消息,同一 `消息ID` 不重复) + ```bash + python3 "/Users/karuo/Documents/个人/卡若AI/01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/realtime_chat_sync.py" + ``` +2. **(可选)** 若本轮明确知道对话 ID,可指定: + `python3 .../realtime_chat_sync.py --current-conversation-id ` + +3. 再写 **强制复盘**(🎯📌💡📝▶),见 `卡若复盘格式_固定规则.md`。 + +--- + +## 二、本机/新环境首次或索引报错时 + +1. **去重并建唯一索引**(库层防重复;大表建索引可能需数十秒~数分钟) + ```bash + python3 "/Users/karuo/Documents/个人/卡若AI/01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/ensure_mongo_chat_indexes.py" + ``` +2. 或使用入口: + `python3 .../realtime_chat_sync.py --ensure-indexes` + +--- + +## 三、补全本地全部 Cursor 历史(按需、低频) + +```bash +python3 "/Users/karuo/Documents/个人/卡若AI/01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/realtime_chat_sync.py" --sync-all +``` + +仅补库里**尚未出现**的 `对话ID`:加 `--only-new`。 + +--- + +## 四、存储与优化要点(给 Agent 的人类可读摘要) + +| 项 | 说明 | +|:---|:---| +| 不重复键 | `对话记录` 以 **`对话ID`** upsert;`消息内容` 以 **`对话ID` + `消息ID`** upsert;唯一索引见 `ensure_mongo_chat_indexes.py`。 | +| 分类 | `对话记录.项目` 由脚本按路径/标题/内容匹配 15 类;同步成功后会刷新 **`项目分类`** 汇总。 | +| 可视查询 | 官网控制台 **`/console/cursor-archive`**(只读 Mongo,不碰 `state.vscdb`);命令行见 `query_chat_history.py`。 | +| 详细技能 | `01_卡资(金)/金仓_存储备份/聊天记录管理/SKILL.md`(G22) | + +--- + +## 五、与飞书 / Gitea 的相对顺序(建议) + +- **Mongo 同步** → **飞书复盘 webhook**(若有)→ **Gitea 自动同步**(若本轮改仓库文件)→ **复盘块收尾**。 +具体以 `.cursor/rules/karuo-ai.mdc` 为准。 diff --git a/运营中枢/工作台/gitea_push_log.md b/运营中枢/工作台/gitea_push_log.md index 85a7257c..e78cf541 100644 --- a/运营中枢/工作台/gitea_push_log.md +++ b/运营中枢/工作台/gitea_push_log.md @@ -405,3 +405,4 @@ | 2026-03-20 13:39:57 | 🔄 卡若AI 同步 2026-03-20 12:40 | 更新:水桥平台对接 | 排除 >20MB: 11 个 | | 2026-03-20 16:08:50 | 🔄 卡若AI 同步 2026-03-20 16:08 | 更新:水桥平台对接、卡木、运营中枢工作台 | 排除 >20MB: 11 个 | | 2026-03-20 21:38:30 | 🔄 卡若AI 同步 2026-03-20 21:38 | 更新:水桥平台对接、卡木、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 | +| 2026-03-20 21:45:45 | 🔄 卡若AI 同步 2026-03-20 21:45 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | diff --git a/运营中枢/工作台/代码管理.md b/运营中枢/工作台/代码管理.md index 5f291109..28266fbc 100644 --- a/运营中枢/工作台/代码管理.md +++ b/运营中枢/工作台/代码管理.md @@ -408,3 +408,4 @@ | 2026-03-20 13:39:57 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-20 12:40 | 更新:水桥平台对接 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | | 2026-03-20 16:08:50 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-20 16:08 | 更新:水桥平台对接、卡木、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | | 2026-03-20 21:38:30 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-20 21:38 | 更新:水桥平台对接、卡木、总索引与入口、运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) | +| 2026-03-20 21:45:45 | 成功 | 成功 | 🔄 卡若AI 同步 2026-03-20 21:45 | 更新:运营中枢工作台 | 排除 >20MB: 11 个 | [仓库](http://open.quwanzhi.com:3000/fnvtk/karuo-ai) [百科](http://open.quwanzhi.com:3000/fnvtk/karuo-ai/wiki) |