🔄 卡若AI 同步 2026-03-20 23:22 | 更新:Cursor规则、金仓、卡木、总索引与入口、运营中枢参考资料、运营中枢工作台 | 排除 >20MB: 11 个

This commit is contained in:
2026-03-20 23:22:43 +08:00
parent 8f1e5b4c46
commit 9b110019a4
16 changed files with 914 additions and 298 deletions

View File

@@ -37,7 +37,12 @@ alwaysApply: true
- **飞书日志**:直接执行 `write_today_three_focus.py`,不询问
- **对外输出**:报告/图片 → `/Users/karuo/Documents/卡若Ai的文件夹/` 下对应子目录;图片登记 `图片/图片索引.md`
- **数据库**:一律用唯一 MongoDB27017库名 `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 <UUID>`
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、宝塔等保留原文

View File

@@ -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 <UUID>` |
| 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 不可用时最近对话) |

View File

@@ -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",
"名称": "聊天内容复制",
"项目": "未分类",

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 做竖屏中段:**

View File

@@ -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` 建议 **46 个汉字**;成片内封面主标题最多显示 **6 个汉字**(超长由 `soul_enhance` 自动截断,与文件名 `--title-only` 一致)。
- **竖屏**498×1080crop 参数与 `参考资料/竖屏中段裁剪参数说明.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:0568 内水平居中取 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标题用刺激性观点30300 秒完整段;语助词与节奏感见 `参考资料/高光识别提示词.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 |
| 单段时长 | 30300 秒 |
| 高光/语助词 | 见 `参考资料/高光识别提示词.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 输出,与录播切片一致。
---

View File

@@ -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×108016:9soul_enhance 输出
- **需求**:保留画面中间竖条(手机内容区),去左右白边
- **输出****498×1080** 竖屏
- **源视频**:横版 1920×108016:9batch_clip / soul_enhance 前后均可
- **目标**:保留中间「小程序+飞书内容」竖条,去左右白边
- **输出(竖条模式)****W×1080**W 由当场 analyze 打印(常见约 560750随布局而变
- **输出(可选 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` | 包络宽约 598OVERLAY_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

View File

@@ -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}<498center-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常见 560750随场次而变
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__":

View File

@@ -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.py20% 帧、扩边到桌面白 + 横向 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 解析字幕/封面叠在横版上的 xcrop=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
- 两段 cropcrop=W:1080:X:0,crop=498:1080:Y:0 → X+Y
- crop + scale=498crop=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 决定(原生包络常见 560750×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")

View File

@@ -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 = "_竖屏中段"

View File

@@ -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` |
## 八、全局规则

View File

@@ -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` |
## 水组 · 卡人(信息流程调度)

View File

@@ -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 <UUID>`
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` 为准。

View File

@@ -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 个 |

View File

@@ -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) |