🔄 卡若AI 同步 2026-03-20 23:22 | 更新:Cursor规则、金仓、卡木、总索引与入口、运营中枢参考资料、运营中枢工作台 | 排除 >20MB: 11 个
This commit is contained in:
@@ -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 <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、宝塔等保留原文
|
||||
|
||||
@@ -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 不可用时最近对话) |
|
||||
|
||||
@@ -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",
|
||||
"名称": "聊天内容复制",
|
||||
"项目": "未分类",
|
||||
|
||||
125
01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/ensure_mongo_chat_indexes.py
Normal file
125
01_卡资(金)/金仓_存储备份/聊天记录管理/脚本/ensure_mongo_chat_indexes.py
Normal 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()
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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 做竖屏中段:**
|
||||
|
||||
@@ -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 输出,与录播切片一致。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 = "_竖屏中段"
|
||||
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
## 八、全局规则
|
||||
|
||||
|
||||
@@ -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` |
|
||||
|
||||
## 水组 · 卡人(信息流程调度)
|
||||
|
||||
|
||||
58
运营中枢/参考资料/卡若AI_Mongo对话留存闭环.md
Normal file
58
运营中枢/参考资料/卡若AI_Mongo对话留存闭环.md
Normal 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` 为准。
|
||||
@@ -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 个 |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user