@tikagent/skill-tik-video-editor 0.0.0-stage → 1.0.0-preview.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +249 -0
- package/agents/openai.yaml +4 -0
- package/package.json +2 -4
- package/references/editing-guide.md +307 -0
- package/scripts/check-update.js +438 -0
- package/README.md +0 -3
package/SKILL.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tik-video-editor
|
|
3
|
+
description: 剪辑师通过 tik-video-editor-cli 编辑 project.json 剪辑视频,支持素材导入、时间线编辑、字幕、转场、画面缩放(含平滑推镜)、镜像、翻转、画中画、微裁切、调色、视频去重、按音频波形清理低幅度气口、工程读取与回写、预览和导出。适用于创建或继续修改视频剪辑项目。
|
|
4
|
+
metadata:
|
|
5
|
+
version: "1.0.0-preview.1"
|
|
6
|
+
publish: true
|
|
7
|
+
cliMinVersion: "1.5.0"
|
|
8
|
+
registry: "https://registry.npmjs.org/"
|
|
9
|
+
distTag: "preview"
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# 剪辑师:使用 tik-video-editor-cli 剪辑视频
|
|
13
|
+
|
|
14
|
+
本 skill 以剪辑师的角色,通过操作 `tik-video-editor-cli` **编辑 `project.json`** 来剪辑视频(edits videos by **editing `project.json`**)。`project.json` 是 tik-video-editor-cli 的编辑项目文件,保存素材、时间线片段、画幅、FPS 和 revision,记录所有的文件导入和剪辑操作,可供其他 skill 读取、修改和交接。画面缩放(含平滑推镜)、镜像、翻转、画中画、微裁切、调色、视频去重,以及按音频波形清理低幅度气口,也写进工程,不另用 ffmpeg 改视频文件。
|
|
15
|
+
|
|
16
|
+
使用全局安装的 `tik-video-editor-cli` 读取和提交工程、预览及导出;本 skill 不内置 CLI。使用 `get-project`(不加 `--compact`)读取完整工程,局部修改优先用 `patch-project`,完整工程回写用 `set-project` 并保留最近完整读取的 revision,由 CLI 校验冲突并递增版本。
|
|
17
|
+
|
|
18
|
+
对用户可见的文字(含工具 purpose、进度和正文)把本技能角色称为「剪辑师」,把 `tik-video-editor-cli` 称为「剪辑工具」,把本地工作区称为「剪辑工作区」,把语音转写称为「话术识别与分析」。不要对用户说出 `tik-video-editor-cli`、`ffmpeg`。命令、字段和文件名保持原样。
|
|
19
|
+
|
|
20
|
+
## 版本检查
|
|
21
|
+
|
|
22
|
+
运行环境声明技能和 `tik-video-editor-cli` 由宿主维护时(例如提客AI智能体),跳过本节。
|
|
23
|
+
|
|
24
|
+
每个任务开始时先检查本技能,再检查 CLI。同一任务里,同一类错误只自动更新一次;更新后仍然失败就停下,向用户报告原始错误。
|
|
25
|
+
|
|
26
|
+
### 技能
|
|
27
|
+
|
|
28
|
+
用本技能包里的脚本检查。`<本技能目录>` 是本文件所在目录。脚本读取本文件的 `registry` 和 `distTag`;没有这两项时,按 `version` 的后缀选择制品库。读取 CNB 上的 alpha、insider 需要环境变量 `TIK_NPM_TOKEN`。
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
node "<本技能目录>/scripts/check-update.js" --skill tik-video-editor --skill-dir "<本技能目录>"
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`outdated` 为 true 时,在同一命令末尾加上 `--apply` 再运行一次,然后重新读取本文件,再用新的 `cliMinVersion` 做 CLI 检查。`managed` 为 true 时不要加 `--apply`。
|
|
35
|
+
|
|
36
|
+
### CLI
|
|
37
|
+
|
|
38
|
+
- 命令不存在:`npm install -g tik-video-editor-cli@latest`。
|
|
39
|
+
- 已安装:运行 `tik-video-editor-cli version`。`managed` 为 true 时由宿主维护,不要检查也不要升级。否则只比较 `x.y.z`,同号的 `-test` 或 `-rc` 视为达到该正式版。低于 `cliMinVersion` 时运行 `tik-video-editor-cli update --min <cliMinVersion>`。
|
|
40
|
+
- `version` 或 `update` 报 `Unknown command` 时,改为 `npm install -g tik-video-editor-cli@latest`。非托管安装才这样做。
|
|
41
|
+
|
|
42
|
+
## 环境检查
|
|
43
|
+
|
|
44
|
+
同一任务、同一运行环境中,若其他 skill 已成功完成环境检查和登录检查,复用对应结果,不重复检查。
|
|
45
|
+
|
|
46
|
+
运行:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
tik-video-editor-cli doctor
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
检查 Node ≥18、ffmpeg、ffprobe;退出码为 0 且 `ok: true` 才通过。缺少依赖时报告用户并停止任务。
|
|
53
|
+
|
|
54
|
+
## 登录鉴权
|
|
55
|
+
|
|
56
|
+
环境检查后,运行:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
tik-video-editor-cli auth status
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
进行登陆校验,以返回的 `logged_in` 判断登录状态,true 为已登录,false 为未登录。
|
|
63
|
+
|
|
64
|
+
`logged_in: true` 时继续剪辑和转写,不要再运行 `auth login`。`logged_in: false` 时运行:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
tik-video-editor-cli auth login
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
CLI 会输出网页登录地址并尝试打开浏览器,随后自动轮询。把链接提供给用户,让用户在网页输入手机号和验证码、确认授权; 登陆成功后可再继续剪辑。网络错误或登录失败时,直接失败任务,不自动重试。
|
|
71
|
+
|
|
72
|
+
## 执行约束
|
|
73
|
+
|
|
74
|
+
- 剪辑和导出仅通过 `tik-video-editor-cli` 执行,不检查或修改其实现。
|
|
75
|
+
- 画面放大、缩小、平滑缩放、镜像、翻转、画中画、裁切、调色和视频去重,都通过 `tik-video-editor-cli` 修改 `project.json` 的轨道与片段属性完成。不要调用 ffmpeg 处理视频文件。
|
|
76
|
+
- CLI 返回非零退出码时,立即停止并向用户报告原始错误。不要调试或修复 CLI。例外:输出包含 `Unknown command` 或 `Unknown option` 时,先按 CLI 版本检查更新一次,然后重试这一条命令。仍然失败就停止并报告原始错误。
|
|
77
|
+
- 剪辑直接操作本地项目,无需启动 HTTP 服务;交付预览时通过 `status` 启动或复用服务。
|
|
78
|
+
- 调用这些 CLI 时不要设 `background: true`。命令都会结束,下一步依赖标准输出和退出码。`auth login` 的轮询、ASR 和 `export` 也是前台,用 timeout 覆盖预期耗时。
|
|
79
|
+
- 需要 schema、属性、时间语义或剪辑配方时,只读取 [剪辑参考](references/editing-guide.md) 中与当前任务相关的章节。
|
|
80
|
+
|
|
81
|
+
## 用户手动修改
|
|
82
|
+
|
|
83
|
+
用户可以在编辑器里拖动、裁剪、删除片段和换轨,每次保存都会让 `revision` 加 1。继续修改已有工程时:
|
|
84
|
+
|
|
85
|
+
- 以刚读到的工程为准,把这次要做的修改做在上面。工程和记忆、映射或上一轮方案对不上时,按用户改过处理,少掉的片段、变过的时间和轨道都保留。
|
|
86
|
+
- `patch-project` 报错时工程没有任何改动,不是写坏了一半。重新读取工程,按当前片段 ID 重做这次修改。
|
|
87
|
+
- 自己构造整份工程用 `set-project` 时,文档必须由刚读到的完整工程修改而来,不能从记忆、映射或旧方案重新生成再填上当前 `revision`。用户这次明确要求重做、恢复,或在说明冲突后同意按你的方案改时除外。
|
|
88
|
+
- 剪辑原则和目标时长只约束这次新生成的内容,不用来校正用户已改过的部分。
|
|
89
|
+
- 用户的改动让这次要求无法完成或互相矛盾时,说明冲突点并询问用户,同一处只问一次。其他情况照常修改,交付时顺带说明。
|
|
90
|
+
- 交付说明按当前工程描述,和旧脚本对不上的地方不要照抄。
|
|
91
|
+
|
|
92
|
+
## CLI 命令
|
|
93
|
+
|
|
94
|
+
统一调用方式:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
tik-video-editor-cli <命令> <参数>
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
命令在 Windows 上由 cmd 执行,在 macOS/Linux 上由 sh 执行。只用两边含义相同的写法:
|
|
101
|
+
|
|
102
|
+
- 每条命令只调用 `tik-video-editor-cli` 本身。不要套 PowerShell、cmd 或 bash,不要改用 node 加 js 入口或 `.cmd` 的绝对路径。
|
|
103
|
+
- 不用 `;`、`&&`、`|`、重定向、shell 变量和 `$(...)`;占位符直接替换成实际值,含空格的路径用双引号。
|
|
104
|
+
- 传给 `patch-project` / `set-project` 的 JSON 可以用 `--ops` / `--document` 内联,或写入文件后用 `--ops-file` / `--document-file`。文件方式避免命令行参数长度和 shell 引号问题;每一对只能传其中一个。
|
|
105
|
+
|
|
106
|
+
- `version`:输出 `name`、`version`、`managed`。`managed` 为 true 时由桌面端升级,不要执行 `update`。
|
|
107
|
+
- `update [--min x.y.z]`:升级独立安装。当前版本是 `x.y.z-test.N` 时跟随 `test`,否则跟随 `latest`。桌面托管安装会拒绝。
|
|
108
|
+
- `doctor`:检查 Node、ffmpeg、ffprobe,返回 `ok` 与逐项 `checks`。
|
|
109
|
+
- `download --url <URL> --output <路径>`:通用下载,返回绝对 `path`。
|
|
110
|
+
- `auth status`:校验当前 API Key。返回 `logged_in`;已登录时还返回用户信息。
|
|
111
|
+
- `auth login`:发起网页登录授权并轮询,成功后自动保存 API Key。
|
|
112
|
+
- `auth logout`:清除本地 API Key;不会撤销服务端密钥。
|
|
113
|
+
- `create-project`:创建项目。
|
|
114
|
+
- `--name <语义名称>`:必填,按本机当天日期生成 `YYYYMMDD_语义名称`。
|
|
115
|
+
- 返回新项目的 `project_id`(32 位小写十六进制字符串,无横杠)和 `name`,后续命令使用返回的 `project_id`。
|
|
116
|
+
- `get-project`:读取项目时间线。
|
|
117
|
+
- `--project-id <ID>`:必填,目标项目 ID。
|
|
118
|
+
- `--compact`:可选,返回低 token 的素材和片段摘要;省略时返回完整项目 JSON。
|
|
119
|
+
- `patch-project`:批量修改最新项目,同一项目的并发修改按顺序执行。
|
|
120
|
+
- `--project-id <ID>`:必填,目标项目 ID。
|
|
121
|
+
- `--ops '<JSON数组>'` 或 `--ops-file <JSON文件>`:必选其一,按顺序执行的 patch 操作。
|
|
122
|
+
- `preset`:对整条时间线施加画面手法,不用先读工程、也不用手写 ops。只改 `V1` 视频片段的缩放、平滑推镜、镜像、翻转和裁切;调色与画中画是盖在主画面和字幕之上的叠加,默认轨道名是「调色」「画中画」。去重不是这个命令的模式,需要时用命名参数把用途写进轨道名。主视频轨或已有字幕的 `V2` 还没有名字时,会补上「主画面」「字幕」。不改片段顺序、入点、时长、倍速和字幕文本。命令不存在时按上文 CLI 版本检查更新一次再试。
|
|
123
|
+
- `--project-id <ID>`:必填。
|
|
124
|
+
- `--zoom <倍数|off>`:`V1` 视频静态缩放,`0.1`–`4`;`off` 或 `1` 恢复为 `1`,并去掉 `scale` 关键帧。
|
|
125
|
+
- `--smooth-zoom <终点倍数|off>`、`--ease <linear|ease-in|ease-out|ease-in-out>`:按每个 `V1` 片段自己的时长做缩放关键帧,静态 `scale` 归 `1`。终点倍数 `1.01`–`2`。不要和 `--zoom` 同时用。
|
|
126
|
+
- `--mirror <all|alternate|off>`、`--flip-v <on|off>`:水平镜像或垂直翻转。`alternate` 按 `V1` 视频片段的时间顺序交替。
|
|
127
|
+
- `--crop <百分比|off>`:四边同一百分比,`0`–`20`。
|
|
128
|
+
- `--grade <subtle|warm|cold|cinematic|off>`,以及 `--brightness`、`--contrast`、`--saturation`、`--temperature`、`--grain`:跨全片调整层,固定片段 `c_preset_adjust`。`subtle` 是亮度 102、对比 101、饱和 102、颗粒 2;`warm` / `cold` 只加轻微冷暖;`cinematic` 使用电影感预设。重复执行会更新这一条,不会再叠一条。
|
|
129
|
+
- `--grade-track-name <名称>`、`--grade-name <名称>`:轨道和片段名称。不传时,新建用「调色」「画面调色」;已有名称则保留。
|
|
130
|
+
- `--pip <auto|素材ID|off>`,以及 `--pip-scale`、`--pip-corner <top-right|top-left|bottom-right|bottom-left>`、`--pip-opacity`:画中画,固定片段 `c_preset_pip`,默认静音、缩放 `0.28`、右上角。`auto` 复用第一条 `V1` 视频的素材。
|
|
131
|
+
- `--pip-track-name <名称>`、`--pip-name <名称>`:不传时,新建用「画中画」。
|
|
132
|
+
- `--reset`:恢复 `V1` 视频的缩放、推镜、镜像、翻转和裁切,并移除上述调色层和画中画。不能和其他参数一起用。
|
|
133
|
+
- `analyze-audio`:按音频波形找出幅度很小的气口或静音,并给出可提交的剪辑操作。不要为这件事调用 ffmpeg,也不要自己解码音频或写临时脚本。命令不存在时按上文 CLI 版本检查更新一次再试。
|
|
134
|
+
- `--project-id <ID>`:必填。
|
|
135
|
+
- `--media-id <ID>`:可选,只分析这一条素材。不传时分析当前时间线 `V1` 上的视频;没有视频时分析 `A1` 音频。时间线是空的,就按已导入的音视频素材规划会放上时间线的片段。
|
|
136
|
+
- `--preset <conservative|balanced|aggressive>`:可选,默认 `balanced`。结果里总会给出保守、标准、激进三档各自预计剪掉多少。
|
|
137
|
+
- `--threshold <0到1的幅度或负分贝>`:可选。显式指定多低算「幅度很小」,覆盖所选档位的阈值。`0.03` 表示波形高度,`-30` 或 `-30dB` 表示分贝。
|
|
138
|
+
- `--min-duration`、`--padding`、`--min-gap`、`--min-keep`:可选,单位秒。不传就用所选档位的默认值。短于 `min-duration` 的低幅不算气口;切口两侧留 `padding`;扣掉留白后短于 `min-gap` 的不下刀;留下的子片段短于 `min-keep` 时改为修剪首尾,避免把一句切碎。
|
|
139
|
+
- `--ops-out <JSON路径>`:只把 patch 操作写入文件,不改工程。
|
|
140
|
+
- `--apply`:把操作写入当前工程,revision 加 1。带 `linkGroup` 的关联音轨会一起切开,字幕以及已有的全片调色、画中画会跟着缩短。有变速关键帧的片段不切开。
|
|
141
|
+
- 标准输出是 JSON。`audioStats` 含振幅分位数,以及是否有持续底噪;`presets` 是三档的阈值和预计剪掉的秒数;`affectedClips` 含片段台词、低幅区间和保留区间;`summary` 含原时长、新时长、操作条数和 `warnings`。
|
|
142
|
+
- `set-project`:替换完整项目 JSON。
|
|
143
|
+
- `--project-id <ID>`:必填,目标项目 ID。
|
|
144
|
+
- `--document '<JSON对象>'` 或 `--document-file <JSON文件路径>`:必选其一,基于最近一次完整读取修改后的项目文档;文件方式避免命令行参数长度限制。
|
|
145
|
+
- `--verify`:可选,提交后按共用时间线规范化规则核对完整读回(含轨道和片段),成功返回 `verified: true` 和完整工程 `document`;失败时先检查实际工程,不盲目重试提交。
|
|
146
|
+
- 文档保留最近完整读取的 `revision`;CLI 校验冲突,成功后 revision 增加 1。
|
|
147
|
+
- `--force`:可选,仅在用户明确要求丢弃并发修改时使用。
|
|
148
|
+
- `import-media`:导入本地素材并注册到项目。
|
|
149
|
+
- `--project-id <ID>`:必填,目标项目 ID。
|
|
150
|
+
- `--path <绝对路径>`:必填,本地视频、音频、图片或 SVG 文件。
|
|
151
|
+
- `--asr-url <URL>`:可选,原始素材完整 ASR JSON 的 HTTP(S) 地址,保存为 `media.asrUrl`,随工程交付供其他设备复用。
|
|
152
|
+
- `--asr-local-path <本地JSON路径>`:可选,原始素材完整 ASR JSON 文件的本地路径,保存为 `media.asrLocalPath`。
|
|
153
|
+
- `--keep-canvas`:可选。默认时,工程还没有视频或图片、并且没有片段,首次导入视频或图片会把画布改成该素材宽高的偶数化值(奇数向上取偶)。之后的导入不再改画布,帧率也不跟随源文件。空工程在此之前仍是 1280×720、30fps。加上本参数则保持当前画布。
|
|
154
|
+
- 返回值在原有字段外增加 `canvas`:`{width, height, adapted}`。`adapted` 为 true 表示这次导入改写了画布。
|
|
155
|
+
- `media --action asr --project-id <id> --media-id <id> [--output <路径>]`:对media文件进行语音转文字asr,可用于基于文字进行视频剪辑。优先复用本地转写或 URL,无绑定时转写;自动保存并绑定 `asrLocalPath`/`asrUrl`。
|
|
156
|
+
- `status`:检查本地 HTTP 服务,未启动时自动启动。
|
|
157
|
+
- `--project-id <ID>`:可选,指定时返回该项目的 `projectUrl`,用于交付预览。
|
|
158
|
+
- `export`:自动启动所需服务,用无头 Chrome/Chromium 调用与预览相同的浏览器合成器,导出最终 MP4。
|
|
159
|
+
- `--project-id <ID>`:必填,目标项目 ID。
|
|
160
|
+
- `--output <路径>`:可选,本地输出文件;默认使用项目名。
|
|
161
|
+
- `--name <名称>`:可选,服务端导出名称。
|
|
162
|
+
- `--force`:可选,覆盖已存在的本地输出文件。
|
|
163
|
+
- `--browser <路径>`:可选,指定 Chrome/Chromium。
|
|
164
|
+
- `--timeout <秒>`:可选,默认 3600 秒。
|
|
165
|
+
- `--help`:查看命令或子命令帮助。
|
|
166
|
+
|
|
167
|
+
`patch-project` 支持以下操作:
|
|
168
|
+
|
|
169
|
+
- `addClip`:添加片段;可省略 `clip.id`。
|
|
170
|
+
- `updateClip`:用 `id` 定位并通过 `set` 更新;`props` 按键合并。
|
|
171
|
+
- `removeClip`:用 `id` 删除片段。
|
|
172
|
+
- `addMedia`:注册已有远程素材,可在 `media.asrUrl` 附带 ASR 结果地址;本地文件优先使用 `import-media`。
|
|
173
|
+
- `removeMedia`:删除未被片段引用的素材记录。
|
|
174
|
+
- `setProject`:修改 `name`、`width`、`height`、`fps`、`background`、`markers`、`disabledTracks`、`tracks`。`tracks` 的每一项是 `{id, kind, name}`;`name` 可选,是给用户看的轨道名。要改名时先读完整 `tracks` 再整表写回,不要只提交一条新轨道。
|
|
175
|
+
|
|
176
|
+
在 `set` 中把字段设为 `null` 可删除该字段。`keyframes`、`transitionIn`、`transitionOut` 等顶层对象会整体替换,只有 `props` 按键合并。
|
|
177
|
+
|
|
178
|
+
## workflow工作流
|
|
179
|
+
|
|
180
|
+
### 1. 创建或确定项目
|
|
181
|
+
|
|
182
|
+
- 用户指定已有项目 ID 时,使用该 ID,并先运行 `get-project --compact` 确认目标项目。
|
|
183
|
+
- 用户要求新建项目或没有可用项目时,运行 `create-project`,记录返回的项目 ID。不要猜测已有项目 ID。
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
tik-video-editor-cli create-project --name "产品短片"
|
|
187
|
+
tik-video-editor-cli get-project --project-id <project_id> --compact
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### 2. 导入素材并完成剪辑
|
|
191
|
+
|
|
192
|
+
先读取紧凑时间线,确认素材、片段 ID、轨道和时长。逐个运行 `import-media` 导入本地素材,记录返回的 `media.id`。
|
|
193
|
+
|
|
194
|
+
已有 ASR URL 时随导入传入 `--asr-url`。需要转写内容时,运行 `media --action asr --project-id <id> --media-id <id>` 复用本地结果或下载 URL 并自动绑定;紧凑摘要的 `asr=yes` 仅提示结果存在。链接内容包含 `rich_result` 和 `channel`,时间戳基于绑定的实际素材、单位毫秒。下载失败时报告,不自动重复转写。
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
tik-video-editor-cli import-media --project-id <project_id> --path /absolute/path/intro.mp4 --asr-url "https://example.com/intro-asr.json"
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
用户没有指定画幅时,沿用上面这次导入自动得到的画布,不要再写成 1280×720。用户明确要求竖屏、横屏或某个分辨率时,在导入之后用 `setProject` 设置 `width` 和 `height`。未要求跨画幅转换时不要写 `fit`;同画幅下默认 `contain` 就是完整画面。只有用户希望铺满并接受裁切时才用 `fit: "cover"`,要完整保留画面、允许留边时用 `fit: "contain"`。
|
|
201
|
+
|
|
202
|
+
根据任务读取必要的剪辑参考,规划轨道、入点、时长、效果和音频。优先用一次 `patch-project` 提交相关修改,避免中间态。先把操作写入工作目录下的 JSON 文件,例如 `ops.json`:
|
|
203
|
+
|
|
204
|
+
```json
|
|
205
|
+
[
|
|
206
|
+
{"op":"addClip","clip":{"kind":"video","mediaId":"m_demo","track":"V1","start":0,"in":0,"duration":5}},
|
|
207
|
+
{"op":"addClip","clip":{"kind":"text","mediaId":null,"track":"V2","start":0.4,"in":0,"duration":2.5,"props":{"text":"现在开始","font":"Anton","fontSize":96,"textAnim":"word-pop"}}}
|
|
208
|
+
]
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
再提交:
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
tik-video-editor-cli patch-project --project-id <project_id> --ops-file "<ops.json 的绝对路径>"
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
仅在必须替换整个文档时使用 `set-project`。先完整读取项目,再基于该 revision 修改;不要从过期文档覆盖项目,也不要用重新生成的时间线配上当前 revision 提交。提交文件并验证可用 `set-project --project-id <id> --document-file <path> --verify`,参数说明见上方 CLI 命令。`--ops` 与 `--ops-file` 二选一。
|
|
218
|
+
|
|
219
|
+
已有 `project_id` 上做全片缩放、平滑推镜、镜像、翻转、裁切、调色或画中画时,不要新建工程,也不要改口播内容和片段顺序。优先一条 `preset`,不必先 `get-project`,也不要按片段手写 ops。只改某一个片段,或必须保留该片段上已经调好的局部参数时,才读取 [剪辑参考](references/editing-guide.md) 的「画面与轨道预置手法」,用一次 `patch-project --ops-file` 提交。
|
|
220
|
+
|
|
221
|
+
去重是业务目的,不是另一套手法。用户要去重时,按「视频去重」从上面的手法里选择组合,并把因此新建的轨道命名为「调色(去重)」「画中画(去重)」。常规调色和画中画不要加上「去重」。
|
|
222
|
+
|
|
223
|
+
用户要求去掉波形幅度很小的小片段、气口或静音停顿时,不要新建工程,也不要调用 ffmpeg 或临时脚本。对当前工程运行 `analyze-audio --project-id <project_id>`,先看 `audioStats`、`presets` 和 `affectedClips`。没有额外要求时用默认的 `balanced`。`audioStats.hasContinuousFloor` 为 true,或标准档剪掉的时长明显少于用户预期时,把三档预计结果告诉用户,并问要不要改用 `aggressive`。档位或 `--threshold` 确定后,用同一组参数加上 `--apply` 写入工程。需要先改某几处时,用 `--ops-out` 得到操作文件,改完再 `patch-project --ops-file`。`summary.warnings` 里有变速关键帧或其他跳过原因时,向用户说明。细节见 [剪辑参考](references/editing-guide.md) 的「音频波形与气口精剪」。提交后仍按下面的验证交付。
|
|
224
|
+
|
|
225
|
+
### 3. 验证并交付
|
|
226
|
+
|
|
227
|
+
再次运行 `get-project --compact`,核对总时长、轨道、素材引用和片段边界;关键帧和转场需读取完整项目核对。验证后运行:
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
tik-video-editor-cli status --project-id <project_id>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
使用返回的 `projectUrl` 交付预览,默认不直接导出。简述剪辑结果,并提示:“可以继续提出调整,或回复‘导出最终视频’进行导出。”不展示与用户无关的内部细节。
|
|
234
|
+
|
|
235
|
+
用户回复“导出最终视频”或已明确要求最终文件时,运行:
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
tik-video-editor-cli export --project-id <project_id> --output ./product-reel.mp4
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## 剪辑原则
|
|
242
|
+
|
|
243
|
+
- 满足 `in + duration × speed ≤ media.duration`;变速关键帧需按速度积分检查源素材消耗。
|
|
244
|
+
- 视频画面放 V1,文字、叠加和调整层放更高视频轨;音乐、对白和音效使用音频轨。
|
|
245
|
+
- 让非专业用户看懂时间线。新建或使用轨道时写上中文用途,调整层和叠加片段也写 `name`。常规剪辑用功能名:`主画面`、`字幕`、`标题与花字`、`调色`、`画中画`、`贴纸与角标`、`原声对白`、`背景音乐`、`音效`。同一手法若是为了某个业务目的,在功能名后注明,例如 `调色(去重)`、`画中画(去重)`。不要留空,让轨道头只显示「视频1」「视频2」。
|
|
246
|
+
- 相关修改合并到一次 patch、一次 `preset` 或一次 `analyze-audio --apply`,避免多次往返和中间态。
|
|
247
|
+
- 清理低幅度气口只用 `analyze-audio`。不要调用 ffmpeg 探测响度、静音或转出音频,也不要自己按采样点计算时间。
|
|
248
|
+
- 字幕与标题按用途选择字体,不要整条片重复同一种展示字体。
|
|
249
|
+
- 交付前检查画幅、FPS、响度、字幕安全区、空隙和片尾音频淡出。
|
package/package.json
CHANGED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# tik-video-editor-cli 剪辑参考
|
|
2
|
+
|
|
3
|
+
## 目录
|
|
4
|
+
|
|
5
|
+
- 项目结构
|
|
6
|
+
- 片段语义
|
|
7
|
+
- 属性参考
|
|
8
|
+
- 文本与动画
|
|
9
|
+
- 关键帧、转场和调整层
|
|
10
|
+
- 动画 SVG
|
|
11
|
+
- 常用配方
|
|
12
|
+
- 画面与轨道预置手法
|
|
13
|
+
- 视频去重
|
|
14
|
+
- 音频波形与气口精剪
|
|
15
|
+
- 交付检查
|
|
16
|
+
|
|
17
|
+
## 项目结构
|
|
18
|
+
|
|
19
|
+
时间线是一个 JSON 文档。空工程初始画布是 1280×720、30fps。工程还没有视频或图片、并且没有片段时,首次 `import-media` 导入视频或图片会把画布改成该素材的偶数宽高;不要把下面的数字照抄到另一条素材上。用户指定画幅时,在导入之后用 `setProject` 写入。
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"name": "My Edit",
|
|
24
|
+
"width": 1920,
|
|
25
|
+
"height": 1080,
|
|
26
|
+
"fps": 30,
|
|
27
|
+
"background": "#000000",
|
|
28
|
+
"revision": 7,
|
|
29
|
+
"markers": [{"t": 2.5}, {"t": 5, "label": "drop"}],
|
|
30
|
+
"inPoint": 0,
|
|
31
|
+
"outPoint": 12,
|
|
32
|
+
"disabledTracks": ["A2"],
|
|
33
|
+
"media": [
|
|
34
|
+
{
|
|
35
|
+
"id": "m_intro",
|
|
36
|
+
"name": "intro.mp4",
|
|
37
|
+
"kind": "video",
|
|
38
|
+
"src": "/projects/my-edit/media/intro.mp4",
|
|
39
|
+
"asrUrl": "https://example.com/intro-asr.json",
|
|
40
|
+
"duration": 12.4,
|
|
41
|
+
"width": 1920,
|
|
42
|
+
"height": 1080
|
|
43
|
+
}
|
|
44
|
+
],
|
|
45
|
+
"clips": [
|
|
46
|
+
{
|
|
47
|
+
"id": "c_intro",
|
|
48
|
+
"mediaId": "m_intro",
|
|
49
|
+
"kind": "video",
|
|
50
|
+
"track": "V1",
|
|
51
|
+
"start": 0,
|
|
52
|
+
"in": 2.5,
|
|
53
|
+
"duration": 5,
|
|
54
|
+
"props": {"fit": "cover"},
|
|
55
|
+
"keyframes": {
|
|
56
|
+
"scale": [{"t": 0, "v": 1}, {"t": 5, "v": 1.2, "ease": "linear"}]
|
|
57
|
+
},
|
|
58
|
+
"transitionOut": {"type": "fade", "duration": 0.5}
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`media.kind` 支持 `video`、`audio`、`image`、`svg`。文本和调整层没有媒体记录。
|
|
65
|
+
|
|
66
|
+
`media.asrUrl` 可选,保存完整原始素材的 ASR JSON HTTP(S) 地址。JSON 包含 `rich_result` 与 `channel`,时间戳为源素材毫秒;剪切、变速不改变它。导入时通过 `--asr-url` 保存,跨设备按该地址下载复用。
|
|
67
|
+
|
|
68
|
+
`clip.kind` 支持 `video`、`audio`、`image`、`svg`、`text`、`adjust`。`text` 和 `adjust` 的 `mediaId` 为 `null`。
|
|
69
|
+
|
|
70
|
+
`tracks[].name` 可选。时间线轨道头优先显示这个名字,没有名字时仍显示「视频1」「音频1」。名字只给用户看,不参与合成。写名字时提交完整 `tracks`,保留已有轨道的 `id`、`kind` 和顺序。
|
|
71
|
+
|
|
72
|
+
## 片段语义
|
|
73
|
+
|
|
74
|
+
- `start`:片段在时间线中的起点,单位秒。
|
|
75
|
+
- `in`:源素材入点,单位秒;图片、SVG、文本和调整层通常为 0。
|
|
76
|
+
- `duration`:时间线长度,单位秒。
|
|
77
|
+
- `track`:V1 先绘制,V2、V3 依次覆盖;A1–A4 放独立音频或视频的关联声道。
|
|
78
|
+
- `linkGroup`:画面与关联音频共享的可选联动 ID。
|
|
79
|
+
- 静态变速必须满足 `in + duration × speed ≤ media.duration`。
|
|
80
|
+
- 切分片段时,前段长度为 `t`;后段的 `start` 增加 `t`,`in` 增加 `t × speed`。
|
|
81
|
+
- 同轨重叠加后一个片段的淡入转场即为交叉淡化。
|
|
82
|
+
- 缺省属性使用默认值;不要为了完整而写入所有默认属性。
|
|
83
|
+
- 修改完整文档时必须基于最新 revision;优先使用 merge-safe patch。
|
|
84
|
+
|
|
85
|
+
## 属性参考
|
|
86
|
+
|
|
87
|
+
### 变换与合成
|
|
88
|
+
|
|
89
|
+
| 属性 | 默认值 | 说明 |
|
|
90
|
+
| --- | --- | --- |
|
|
91
|
+
| `x`、`y` | 0 | 相对画布中心的像素偏移 |
|
|
92
|
+
| `scale` | 1 | 缩放 |
|
|
93
|
+
| `rotation` | 0 | 角度 |
|
|
94
|
+
| `opacity` | 1 | 0–1 |
|
|
95
|
+
| `blend` | `normal` | `multiply`、`screen`、`overlay`、`lighter`、`soft-light`、`hard-light`、`color-dodge`、`darken`、`lighten`、`difference` |
|
|
96
|
+
|
|
97
|
+
### 布局
|
|
98
|
+
|
|
99
|
+
| 属性 | 默认值 | 说明 |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `fit` | `contain` | `contain`、`cover`、`stretch`、`none` |
|
|
102
|
+
| `cropL/R/T/B` | 0 | 各边裁切百分比 |
|
|
103
|
+
| `cornerRadius` | 0 | 圆角像素 |
|
|
104
|
+
| `flipH`、`flipV` | false | 水平或垂直翻转 |
|
|
105
|
+
|
|
106
|
+
### 调色与特效
|
|
107
|
+
|
|
108
|
+
| 属性 | 默认值 | 说明 |
|
|
109
|
+
| --- | --- | --- |
|
|
110
|
+
| `filterPreset` | `none` | `cinematic`、`teal-orange`、`noir`、`vintage`、`faded`、`warm`、`cold`、`pop`、`dreamy`、`retro`、`bw-soft`、`cyberpunk`、`sunset`、`midnight` |
|
|
111
|
+
| `brightness`、`contrast`、`saturation` | 100 | 百分比,100 为中性 |
|
|
112
|
+
| `hue` | 0 | 色相角度 |
|
|
113
|
+
| `temperature`、`tint` | 0 | -100 到 100 |
|
|
114
|
+
| `blur` | 0 | 模糊像素 |
|
|
115
|
+
| `grayscale`、`sepia`、`invert`、`vignette` | 0 | 百分比 |
|
|
116
|
+
| `shake` | 0 | 镜头抖动像素 |
|
|
117
|
+
| `shakeSpeed` | 8 | 抖动频率 |
|
|
118
|
+
| `rgbSplit` | 0 | RGB 分离像素 |
|
|
119
|
+
| `grain` | 0 | 动态颗粒百分比 |
|
|
120
|
+
|
|
121
|
+
### 抠像与音频
|
|
122
|
+
|
|
123
|
+
| 属性 | 默认值 | 说明 |
|
|
124
|
+
| --- | --- | --- |
|
|
125
|
+
| `chromaKey` | 空 | 键色,例如 `#00ff00` |
|
|
126
|
+
| `chromaTolerance` | 26 | 颜色容差 0–100 |
|
|
127
|
+
| `chromaSoftness` | 12 | 边缘柔化与溢色抑制 0–100 |
|
|
128
|
+
| `bgRemove` | false | 浏览器内人物背景移除,首次使用需要联网 |
|
|
129
|
+
| `volume` | 1 | 音量 0–2 |
|
|
130
|
+
| `speed` | 1 | 播放速度 0.25–4,可设关键帧 |
|
|
131
|
+
|
|
132
|
+
## 文本与动画
|
|
133
|
+
|
|
134
|
+
常用文本属性:
|
|
135
|
+
|
|
136
|
+
| 属性 | 默认值 | 说明 |
|
|
137
|
+
| --- | --- | --- |
|
|
138
|
+
| `text` | `Title` | 支持换行 |
|
|
139
|
+
| `fontSize` | 72 | 字号 |
|
|
140
|
+
| `font` | `Segoe UI` | 系统字体、素材库字体或 Google Font 名称 |
|
|
141
|
+
| `color`、`color2` | 白、空 | 填充色;设置 `color2` 后使用渐变 |
|
|
142
|
+
| `bold`、`weight`、`italic` | true、0、false | `weight` 非零时覆盖 bold |
|
|
143
|
+
| `uppercase` | false | 转为大写 |
|
|
144
|
+
| `align` | `center` | `left`、`center`、`right`、`justify` |
|
|
145
|
+
| `boxW`、`boxH` | 0 | 文本框尺寸;0 表示贴合内容 |
|
|
146
|
+
| `boxFit` | false | 缩小字体以适应文本框 |
|
|
147
|
+
| `vAlign` | `middle` | `top`、`middle`、`bottom` |
|
|
148
|
+
| `direction` | `auto` | `auto`、`ltr`、`rtl` |
|
|
149
|
+
| `letterSpacing`、`lineHeight` | 0、1.2 | 字距与行高 |
|
|
150
|
+
| `strokeWidth`、`strokeColor` | 0、黑 | 描边 |
|
|
151
|
+
| `bgColor`、`bgOpacity` | 黑、0 | 行背景胶囊 |
|
|
152
|
+
| `textShadow` | 12 | 柔和阴影 |
|
|
153
|
+
| `glow`、`glowColor` | 0、空 | 霓虹辉光 |
|
|
154
|
+
| `textAnim` | `none` | `typewriter`、`word-pop`、`word-slide`、`karaoke`、`letter-pop`、`wave`、`bounce`、`shake`、`clip-reveal`、`zoom-in`、`font-cut`、`rise-mask` |
|
|
155
|
+
| `wordRate` | 0.15 | 动画步进秒数 |
|
|
156
|
+
| `fontCutSet` | 内置组合 | `font-cut` 循环使用的字体数组 |
|
|
157
|
+
|
|
158
|
+
展示标题不要整片复用同一种字体。常见组合:
|
|
159
|
+
|
|
160
|
+
- 冲击标题:Anton、全大写、`word-pop`、大阴影。
|
|
161
|
+
- 优雅标题:Playfair Display、白金渐变、`clip-reveal`。
|
|
162
|
+
- 动感标题:Bebas Neue、`font-cut`。
|
|
163
|
+
- 霓虹标题:Bebas Neue、青色 glow、`wave`。
|
|
164
|
+
- 手写标题:Caveat、轻微旋转、`word-slide`。
|
|
165
|
+
- 字幕:Roboto、小字号、底部背景胶囊、`karaoke`。
|
|
166
|
+
- 下三分之一:Archivo Black、`rise-mask`。
|
|
167
|
+
|
|
168
|
+
## 关键帧、转场和调整层
|
|
169
|
+
|
|
170
|
+
关键帧时间相对片段起点。`ease` 写在每段的目标关键帧上,支持 `linear`、`ease-in`、`ease-out`、`ease-in-out`。存在关键帧时,它会覆盖同名静态属性。
|
|
171
|
+
|
|
172
|
+
可动画属性包括 `x`、`y`、`scale`、`rotation`、`opacity`、`volume`、`speed`、主要调色属性、`cornerRadius`、`shake`、`rgbSplit`、`grain`、`fontSize`、`letterSpacing`、`glow`。
|
|
173
|
+
|
|
174
|
+
转场类型:`fade`、`slide-left/right/up/down`、`zoom`、`wipe-left/right/up/down`、`iris`、`spin`、`blur`、`whip`、`glitch`、`pop`。转场叠加在关键帧结果上;fade 同时作用于音频。
|
|
175
|
+
|
|
176
|
+
调整层使用 `kind: "adjust"`、`mediaId: null`,放在 V2/V3,对下方画面统一应用调色、颗粒、RGB 分离、抖动和透明度。
|
|
177
|
+
|
|
178
|
+
## 动画 SVG
|
|
179
|
+
|
|
180
|
+
- 根 `<svg>` 设置 `width`、`height` 或 `viewBox`。
|
|
181
|
+
- 使用 `<style>` 中的 CSS `@keyframes`;不要使用 SMIL `<animate>`。
|
|
182
|
+
- 不写固定 `animation-delay`,改用元素属性 `style="--d:0.4s"`。
|
|
183
|
+
- 一次性动画使用 `animation-fill-mode: both`,循环动画使用 `infinite`。
|
|
184
|
+
- 自身中心旋转或缩放时设置 `transform-box: fill-box; transform-origin: center`。
|
|
185
|
+
- 保持文件自包含,不引用外部资源。
|
|
186
|
+
- 用 `import-media` 导入 SVG,再作为 `kind: "svg"` 的片段放到视频轨。
|
|
187
|
+
|
|
188
|
+
## 常用配方
|
|
189
|
+
|
|
190
|
+
- 粗剪:V1 片段首尾相接,后一个 `start` 等于前面时长累加。
|
|
191
|
+
- 标题卡:文本片段放更高视频轨,轨道名 `标题与花字`,片段写明标题内容。设置 `text`、`font`、`fontSize`、`color`。
|
|
192
|
+
- 音乐底:音频放 A1,轨道名 `背景音乐`,`volume: 0.2–0.4`,片尾淡出。原声对白轨道名 `原声对白`。
|
|
193
|
+
- 电影调色:跨全片调整层使用 `filterPreset: "cinematic"` 和少量 grain,轨道名 `调色`,片段名写明风格,例如 `电影感调色`。
|
|
194
|
+
- 绿幕:主体放更高视频轨,设置 `chromaKey`、容差与柔化;背景放 V1,轨道名 `主画面`。
|
|
195
|
+
- 画中画:更高视频轨使用 `scale: 0.35`、位置偏移、圆角和 slide 转场,轨道名和片段名都是 `画中画`。
|
|
196
|
+
- Ken Burns:图片的 scale 从 1 线性到 1.2,并缓慢移动 x 或 y。
|
|
197
|
+
- 交叉淡化:同轨重叠约 1 秒,后片段设置 fade 入场。
|
|
198
|
+
- 鞭甩切换:前后片段使用约 0.25 秒 whip 转场。
|
|
199
|
+
- 节拍剪辑:把节拍时间写入 markers,并让片段起点对齐 markers。
|
|
200
|
+
- 变速落点:先快后慢的 speed 关键帧在节拍处落到慢速;确认源素材足够。
|
|
201
|
+
- 冲击帧:V3 放 0.25–0.4 秒调整层,设置 shake 与 rgbSplit;A2 对齐冲击音效。
|
|
202
|
+
- 霓虹字幕:白字、青色 glow、Bebas Neue、`wave`。
|
|
203
|
+
- 短视频画幅:用户要求竖屏时把项目设为 1080×1920(或用户指定的分辨率),字幕避开上下平台 UI 区域。未要求时沿用首个视频或图片的分辨率。
|
|
204
|
+
|
|
205
|
+
## 画面与轨道预置手法
|
|
206
|
+
|
|
207
|
+
缩放、平滑推镜、镜像、翻转、裁切、调色和画中画是通用剪辑能力。全片都要改时用一条 `preset`,不要把这些手法叫成去重,也不要在常规操作的轨道名里写「去重」。
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
tik-video-editor-cli preset --project-id <project_id> --zoom 1.2
|
|
211
|
+
tik-video-editor-cli preset --project-id <project_id> --smooth-zoom 1.08 --ease ease-in-out
|
|
212
|
+
tik-video-editor-cli preset --project-id <project_id> --mirror alternate --flip-v off
|
|
213
|
+
tik-video-editor-cli preset --project-id <project_id> --crop 2
|
|
214
|
+
tik-video-editor-cli preset --project-id <project_id> --grade warm
|
|
215
|
+
tik-video-editor-cli preset --project-id <project_id> --pip auto --pip-scale 0.3 --pip-corner top-right
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`preset` 只改 `V1` 上的视频片段。调整层固定为 `c_preset_adjust`,画中画固定为 `c_preset_pip`;再执行一次会更新原片段,不会叠出第二条。两条叠加都放在主画面和字幕之上,所以调色和颗粒也会作用到已经画上去的字幕。字幕片段本身不要写翻转或裁切。主视频轨或已有字幕的 `V2` 还没有名字时,会补上「主画面」「字幕」。`--reset` 把 `V1` 视频的缩放、镜像、翻转和裁切恢复成默认,并移除这两条叠加;它不记得修改前的数值。
|
|
219
|
+
|
|
220
|
+
几何变换不能交给 `kind: "adjust"` 的调整层。调整层只有调色、模糊、颗粒、RGB 分离、抖动和透明度。
|
|
221
|
+
|
|
222
|
+
只改一个片段时再用 `patch-project`。`props` 按键合并。`keyframes` 会整体替换:片段上还有其它关键帧时,把原有关键帧一并写回,不要只留下 `scale`。
|
|
223
|
+
|
|
224
|
+
### 缩放与平滑推镜
|
|
225
|
+
|
|
226
|
+
静态缩放写 `props.scale`。用户说「放大一点点」时取 `1.03`–`1.06`,未指定就用 `1.04`;用户给出倍数时用用户的数。平滑推镜用相对片段起点的 `scale` 关键帧:起点 `t` 为 `0`、`v` 为 `1`,终点 `t` 等于该片段自己的 `duration`。不要给所有片段抄同一个 `t`。
|
|
227
|
+
|
|
228
|
+
```json
|
|
229
|
+
{"op":"updateClip","id":"c_1","set":{"props":{"scale":1.04}}}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
```json
|
|
233
|
+
{"op":"updateClip","id":"c_1","set":{"props":{"scale":1},"keyframes":{"scale":[{"t":0,"v":1},{"t":5,"v":1.08,"ease":"ease-in-out"}]}}}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### 镜像、翻转与裁切
|
|
237
|
+
|
|
238
|
+
水平镜像是 `flipH`,垂直翻转是 `flipV`。全片镜像时每个 `V1` 视频片段都写;分段镜像按时间顺序交替,切换落在已有片段边界,不把一个片段拆开。`cropL`、`cropR`、`cropT`、`cropB` 是各边裁掉的百分比,四边一起裁,避免把主体推偏。
|
|
239
|
+
|
|
240
|
+
```json
|
|
241
|
+
{"op":"updateClip","id":"c_1","set":{"props":{"flipH":true,"cropL":1,"cropR":1,"cropT":1,"cropB":1}}}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### 调色
|
|
245
|
+
|
|
246
|
+
跨全片调整层的轨道名是 `调色`,片段名写明具体风格,例如 `暖色提亮` 或 `画面调色`。`start` 为 `0`,`duration` 为时间线总时长。颜色本身是内容时不要改色相,也不要套明显滤镜。
|
|
247
|
+
|
|
248
|
+
```json
|
|
249
|
+
{"op":"addClip","clip":{"id":"c_preset_adjust","kind":"adjust","mediaId":null,"name":"画面调色","track":"V4","start":0,"in":0,"duration":12,"props":{"brightness":102,"contrast":101,"saturation":102,"grain":2}}}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
同时把这条轨道的 `name` 写成 `调色`。轨道要高于主画面和字幕,且不要和画中画占同一条轨。
|
|
253
|
+
|
|
254
|
+
### 画中画
|
|
255
|
+
|
|
256
|
+
轨道名和片段名都是 `画中画`。`scale` 取 `0.25`–`0.4`,`x`、`y` 是相对画布中心的像素偏移,`cornerRadius` 取 `12`–`24`。不需要原声时设 `volume: 0`。需要入场时再加 `transitionIn`。
|
|
257
|
+
|
|
258
|
+
```json
|
|
259
|
+
{"op":"addClip","clip":{"id":"c_preset_pip","kind":"video","mediaId":"m_pip","name":"画中画","track":"V3","start":0,"in":0,"duration":5,"props":{"scale":0.32,"x":420,"y":-240,"cornerRadius":16,"volume":0}}}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## 视频去重
|
|
263
|
+
|
|
264
|
+
去重是把上一节的手法组合起来用,不是另一组参数,也不是另一个命令。用户只点名一种手法时只做该手法。用户只说「去重」时,用轻微放大加热微的调色,不要默认镜像:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
tik-video-editor-cli preset --project-id <project_id> --zoom 1.04 --grade subtle --grade-track-name "调色(去重)" --grade-name "轻微调色(去重)"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
用户明确要求平滑推镜时,用 `--smooth-zoom 1.05` 代替 `--zoom`,仍然带上 `调色(去重)`。只有用户确认画面没有文字、标识和左右不对称问题时,才额外加 `--mirror alternate`。四边微裁切用 `--crop 1` 或 `2`。要加画中画时再加 `--pip auto --pip-track-name "画中画(去重)" --pip-name "画中画(去重)"`。
|
|
271
|
+
|
|
272
|
+
这些名字里的「去重」只在这次操作确实是为了去重时才写。用户只是要调色或画中画时,轨道名仍是 `调色`、`画中画`。
|
|
273
|
+
|
|
274
|
+
去重不改片段顺序、入点、时长、倍速和字幕文本。不声称平台不会判重。商品颜色是卖点时不要用明显滤镜;画面里的文字、标识或不对称款式不要镜像。
|
|
275
|
+
|
|
276
|
+
## 音频波形与气口精剪
|
|
277
|
+
|
|
278
|
+
用户要把波形幅度很小的小片段、气口或静音停顿剪掉时,用 `analyze-audio`。它读取剪辑工具已经缓存的波形,按 20 毫秒一帧判断低幅区间,并处理倍速、关联音轨、字幕和全片调色、画中画的时间对齐。不要调用 ffmpeg,也不要自己把音轨解出来计算。
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
tik-video-editor-cli analyze-audio --project-id <project_id>
|
|
282
|
+
tik-video-editor-cli analyze-audio --project-id <project_id> --preset aggressive --apply
|
|
283
|
+
tik-video-editor-cli analyze-audio --project-id <project_id> --threshold -32 --ops-out "<ops.json 的绝对路径>"
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
不传 `--media-id` 时,有 `V1` 视频就分析这些视频片段;没有视频才分析 `A1`。时间线还是空的,就根据已导入素材规划新片段。素材不在当前时间线上时,命令只返回这段素材的低幅区间,不会改工程。
|
|
287
|
+
|
|
288
|
+
先看返回 JSON,再决定写不写进工程:
|
|
289
|
+
|
|
290
|
+
- `audioStats.percentilesDb` 是这段被分析音频的振幅分布。`hasContinuousFloor` 为 true 表示有持续底噪或背景声,固定的「几乎没声音」标准通常剪不出多少。`floorHint` 会说明这一点。
|
|
291
|
+
- `presets.conservative`、`balanced`、`aggressive` 分别给出阈值、气口处数和预计剪掉的时间线秒数。默认执行 `balanced`。背景声很明显、用户又觉得剪得不够时,把三档数字告诉用户,确认后再用 `--preset aggressive`。
|
|
292
|
+
- `affectedClips` 只列真的会被切开的片段,里面有台词 `name`、源素材上的 `quietGaps` 和切完后的 `keptSegments`。向用户说明剪了哪些话之间的气口时用这些字段,不要复述整份波形。
|
|
293
|
+
- `summary.warnings` 里是没切开的原因,例如片段有变速关键帧。照实告诉用户。
|
|
294
|
+
- `summary.timelineBefore` 和 `timelineAfter` 是工程总时长。用它们核对读回结果。
|
|
295
|
+
|
|
296
|
+
用户接受方案后加 `--apply`。想先改掉某一处时,用 `--ops-out` 写出操作文件,只改需要保留的气口,再 `patch-project --ops-file`。不要按 `affectedClips` 手算每个片段的新 `start`。`--apply` 和之后的 `patch-project` 都会让 revision 增加;写入前工程被别人改过时,命令会拒绝,重新分析一次即可。
|
|
297
|
+
|
|
298
|
+
有变速关键帧的片段保持原样。关联音轨和主画面一起切开,字幕只跟着前移或缩短,不改字幕文字。
|
|
299
|
+
|
|
300
|
+
## 交付检查
|
|
301
|
+
|
|
302
|
+
- 确认项目宽高与 FPS 符合发布平台。
|
|
303
|
+
- 检查片段是否越过源素材时长,是否存在意外黑场或公共空隙。
|
|
304
|
+
- 检查对白、音乐和音效的相对响度,给音乐片尾添加淡出。
|
|
305
|
+
- 检查字幕拼写、阅读速度、对比度、方向和安全区。
|
|
306
|
+
- 检查关键帧时间均在片段 duration 内,转场长度合理。
|
|
307
|
+
- 用 compact 时间线复核轨道、起点、时长和素材引用,再运行 `tik-video-editor-cli status --project-id <ID>`,交付返回的预览链接;用户明确要求后再导出。
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 检查并更新一个已安装的技能。不管 CLI。
|
|
3
|
+
*
|
|
4
|
+
* 用法:node scripts/check-update.js --skill <名称> --skill-dir <目录> [--apply]
|
|
5
|
+
* 制品库和 dist-tag 读目标技能 SKILL.md 的 registry、distTag。
|
|
6
|
+
* 没有这两项时,按 metadata.version 的后缀选择。
|
|
7
|
+
* 成功时向 stdout 打印 JSON。这个文件打进技能包,不单独上传。
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
10
|
+
import fs from 'node:fs'
|
|
11
|
+
import http from 'node:http'
|
|
12
|
+
import https from 'node:https'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { pathToFileURL } from 'node:url'
|
|
15
|
+
import { gunzipSync } from 'node:zlib'
|
|
16
|
+
|
|
17
|
+
const SKILL_VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(alpha|insider|preview)\.(0|[1-9]\d*))?$/
|
|
18
|
+
const PRERELEASE_RANK = { alpha: 0, insider: 1, preview: 2 }
|
|
19
|
+
const CNB_REGISTRY = 'https://npm.cnb.cool/im_tech/tik_web/tik-agent-npm/-/packages/'
|
|
20
|
+
const NPMJS_REGISTRY = 'https://registry.npmjs.org/'
|
|
21
|
+
|
|
22
|
+
export function parseSkillVersion(value) {
|
|
23
|
+
const match = SKILL_VERSION_RE.exec(String(value || ''))
|
|
24
|
+
if (!match) {
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
parts: [Number(match[1]), Number(match[2]), Number(match[3])],
|
|
29
|
+
channel: match[4] || null,
|
|
30
|
+
pre: match[5] === undefined ? null : Number(match[5]),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function compareSkillVersion(left, right) {
|
|
35
|
+
const a = parseSkillVersion(left)
|
|
36
|
+
const b = parseSkillVersion(right)
|
|
37
|
+
if (!a || !b) {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
for (let index = 0; index < 3; index += 1) {
|
|
41
|
+
if (a.parts[index] !== b.parts[index]) {
|
|
42
|
+
return a.parts[index] - b.parts[index]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const rank = parsed => (parsed.channel ? PRERELEASE_RANK[parsed.channel] : 3)
|
|
46
|
+
if (rank(a) !== rank(b)) {
|
|
47
|
+
return rank(a) - rank(b)
|
|
48
|
+
}
|
|
49
|
+
if (a.pre === null && b.pre === null) {
|
|
50
|
+
return 0
|
|
51
|
+
}
|
|
52
|
+
return a.pre - b.pre
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readSkillMetadata(skillMd) {
|
|
56
|
+
if (!skillMd.startsWith('---\n') && !skillMd.startsWith('---\r\n')) {
|
|
57
|
+
return { version: '', cliMinVersion: '', registry: '', distTag: '' }
|
|
58
|
+
}
|
|
59
|
+
const end = skillMd.indexOf('\n---', 4)
|
|
60
|
+
if (end < 0) {
|
|
61
|
+
return { version: '', cliMinVersion: '', registry: '', distTag: '' }
|
|
62
|
+
}
|
|
63
|
+
const frontmatter = skillMd.slice(0, end)
|
|
64
|
+
return {
|
|
65
|
+
version: fieldValue(frontmatter, 'version'),
|
|
66
|
+
cliMinVersion: fieldValue(frontmatter, 'cliMinVersion'),
|
|
67
|
+
registry: fieldValue(frontmatter, 'registry'),
|
|
68
|
+
distTag: fieldValue(frontmatter, 'distTag'),
|
|
69
|
+
dev: fieldValue(frontmatter, 'dev') === 'true',
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function fieldValue(frontmatter, key) {
|
|
74
|
+
const quoted = new RegExp(`^[ \\t]*${key}:\\s*["']([^"']+)["'][ \\t]*$`, 'm').exec(frontmatter)
|
|
75
|
+
if (quoted) {
|
|
76
|
+
return quoted[1]
|
|
77
|
+
}
|
|
78
|
+
const plain = new RegExp(`^[ \\t]*${key}:\\s*([^\\s#"']+)[ \\t]*$`, 'm').exec(frontmatter)
|
|
79
|
+
return plain ? plain[1] : ''
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isLocalDevSkill(skillDir, metadata = {}) {
|
|
83
|
+
if (skillDir && fs.existsSync(path.join(skillDir, '.tik-skill-link.json'))) {
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
if (metadata.dev === true || metadata.dev === 'true') {
|
|
87
|
+
return true
|
|
88
|
+
}
|
|
89
|
+
return /-dev(?:\.\d+)?$/u.test(String(metadata.version || ''))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function packageName(skill) {
|
|
93
|
+
return `@tikagent/skill-${skill}`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function releaseTarget(metadata, overrides = {}) {
|
|
97
|
+
const parsed = parseSkillVersion(metadata.version)
|
|
98
|
+
let distTag = overrides.distTag || metadata.distTag || (parsed ? parsed.channel || 'latest' : '')
|
|
99
|
+
if (distTag === 'stable') {
|
|
100
|
+
distTag = 'latest'
|
|
101
|
+
}
|
|
102
|
+
let registry = overrides.registry || metadata.registry
|
|
103
|
+
if (!registry && (distTag === 'alpha' || distTag === 'insider')) {
|
|
104
|
+
registry = CNB_REGISTRY
|
|
105
|
+
}
|
|
106
|
+
if (!registry && (distTag === 'preview' || distTag === 'latest')) {
|
|
107
|
+
registry = NPMJS_REGISTRY
|
|
108
|
+
}
|
|
109
|
+
if (!distTag || !registry || !['alpha', 'insider', 'preview', 'latest'].includes(distTag)) {
|
|
110
|
+
throw new Error('无法确定技能的制品库和 dist-tag')
|
|
111
|
+
}
|
|
112
|
+
return { distTag, registry: registry.replace(/\/+$/u, '') }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function assertTagVersion(distTag, version) {
|
|
116
|
+
const parsed = parseSkillVersion(version)
|
|
117
|
+
const actual = parsed ? parsed.channel || 'latest' : ''
|
|
118
|
+
if (actual !== distTag) {
|
|
119
|
+
throw new Error(`标签 ${distTag} 指向了 ${version}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function registryHeaders(registry, env = process.env) {
|
|
124
|
+
const headers = { accept: 'application/json' }
|
|
125
|
+
if (!registry.includes('npm.cnb.cool')) {
|
|
126
|
+
return headers
|
|
127
|
+
}
|
|
128
|
+
const token = String(env.TIK_NPM_TOKEN || '').trim()
|
|
129
|
+
if (!token) {
|
|
130
|
+
throw new Error('读取 CNB 制品库需要环境变量 TIK_NPM_TOKEN')
|
|
131
|
+
}
|
|
132
|
+
headers.authorization = `Bearer ${token}`
|
|
133
|
+
return headers
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function verifyDist(buffer, dist) {
|
|
137
|
+
if (!dist || typeof dist !== 'object') {
|
|
138
|
+
throw new Error('技能包缺少 integrity')
|
|
139
|
+
}
|
|
140
|
+
const integrity = String(dist.integrity || '')
|
|
141
|
+
const matched = /^sha(512|384|256|1)-([A-Za-z0-9+/]+={0,2})$/.exec(integrity)
|
|
142
|
+
if (matched) {
|
|
143
|
+
const algo = matched[1] === '1' ? 'sha1' : `sha${matched[1]}`
|
|
144
|
+
const actual = createHash(algo).update(buffer).digest('base64')
|
|
145
|
+
if (actual !== matched[2]) {
|
|
146
|
+
throw new Error('技能包 integrity 不一致')
|
|
147
|
+
}
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
const shasum = String(dist.shasum || '')
|
|
151
|
+
if (/^[0-9a-f]{40}$/i.test(shasum)) {
|
|
152
|
+
const actual = createHash('sha1').update(buffer).digest('hex')
|
|
153
|
+
if (actual !== shasum.toLowerCase()) {
|
|
154
|
+
throw new Error('技能包 shasum 不一致')
|
|
155
|
+
}
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
throw new Error('技能包缺少 integrity')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function cString(buffer) {
|
|
162
|
+
const end = buffer.indexOf(0)
|
|
163
|
+
return (end < 0 ? buffer : buffer.subarray(0, end)).toString('utf8')
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readOctal(buffer) {
|
|
167
|
+
const text = cString(buffer).trim()
|
|
168
|
+
if (!text) {
|
|
169
|
+
return 0
|
|
170
|
+
}
|
|
171
|
+
const value = Number.parseInt(text, 8)
|
|
172
|
+
if (!Number.isFinite(value)) {
|
|
173
|
+
throw new Error('技能包头不合法')
|
|
174
|
+
}
|
|
175
|
+
return value
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function paxPath(data) {
|
|
179
|
+
const text = data.toString('utf8')
|
|
180
|
+
let cursor = 0
|
|
181
|
+
while (cursor < text.length) {
|
|
182
|
+
const space = text.indexOf(' ', cursor)
|
|
183
|
+
if (space < 0) {
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
const length = Number(text.slice(cursor, space))
|
|
187
|
+
if (!Number.isInteger(length) || length <= 0) {
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
const record = text.slice(cursor, cursor + length)
|
|
191
|
+
const body = record.slice(space - cursor + 1).replace(/\n$/, '')
|
|
192
|
+
if (body.startsWith('path=')) {
|
|
193
|
+
return body.slice('path='.length)
|
|
194
|
+
}
|
|
195
|
+
cursor += length
|
|
196
|
+
}
|
|
197
|
+
return ''
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function untar(buffer) {
|
|
201
|
+
const tar = buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b ? gunzipSync(buffer) : buffer
|
|
202
|
+
const files = new Map()
|
|
203
|
+
let overridePath = ''
|
|
204
|
+
let offset = 0
|
|
205
|
+
while (offset + 512 <= tar.length) {
|
|
206
|
+
const header = tar.subarray(offset, offset + 512)
|
|
207
|
+
if (header.every(byte => byte === 0)) {
|
|
208
|
+
break
|
|
209
|
+
}
|
|
210
|
+
const name = cString(header.subarray(0, 100))
|
|
211
|
+
const size = readOctal(header.subarray(124, 136))
|
|
212
|
+
const typeflag = header[156]
|
|
213
|
+
const prefix = cString(header.subarray(345, 500))
|
|
214
|
+
offset += 512
|
|
215
|
+
if (offset + size > tar.length) {
|
|
216
|
+
throw new Error('技能包不完整')
|
|
217
|
+
}
|
|
218
|
+
const data = Buffer.from(tar.subarray(offset, offset + size))
|
|
219
|
+
offset += Math.ceil(size / 512) * 512
|
|
220
|
+
const fallback = prefix ? `${prefix.replace(/\/+$/u, '')}/${name}` : name
|
|
221
|
+
const entryPath = overridePath || fallback
|
|
222
|
+
overridePath = ''
|
|
223
|
+
if (typeflag === 120 || typeflag === 88) {
|
|
224
|
+
overridePath = paxPath(data)
|
|
225
|
+
continue
|
|
226
|
+
}
|
|
227
|
+
if (typeflag === 103 || typeflag === 71 || typeflag === 53) {
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
if ((typeflag !== 0 && typeflag !== 48) || !entryPath || entryPath.endsWith('/')) {
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
files.set(entryPath, data)
|
|
234
|
+
}
|
|
235
|
+
if (files.size === 0) {
|
|
236
|
+
throw new Error('技能包是空的')
|
|
237
|
+
}
|
|
238
|
+
return files
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function locateSkillRoot(staging) {
|
|
242
|
+
if (fs.existsSync(path.join(staging, 'SKILL.md'))) {
|
|
243
|
+
return staging
|
|
244
|
+
}
|
|
245
|
+
const directories = fs.readdirSync(staging, { withFileTypes: true }).filter(entry => entry.isDirectory())
|
|
246
|
+
if (directories.length === 1) {
|
|
247
|
+
const nested = path.join(staging, directories[0]?.name ?? '')
|
|
248
|
+
if (fs.existsSync(path.join(nested, 'SKILL.md'))) {
|
|
249
|
+
return nested
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
throw new Error('技能包里没有 SKILL.md')
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function replaceSkillDir(skillDir, files) {
|
|
256
|
+
const resolved = path.resolve(skillDir)
|
|
257
|
+
const parent = path.dirname(resolved)
|
|
258
|
+
const base = path.basename(resolved)
|
|
259
|
+
const token = randomBytes(4).toString('hex')
|
|
260
|
+
const staging = path.join(parent, `.${base}.staging-${token}`)
|
|
261
|
+
const backup = path.join(parent, `.${base}.backup-${token}`)
|
|
262
|
+
fs.rmSync(staging, { recursive: true, force: true })
|
|
263
|
+
fs.mkdirSync(staging, { recursive: true })
|
|
264
|
+
let moved = false
|
|
265
|
+
try {
|
|
266
|
+
for (const [name, data] of files) {
|
|
267
|
+
if (!name || name.startsWith('/') || name.split('/').includes('..')) {
|
|
268
|
+
throw new Error(`技能包路径不合法:${name}`)
|
|
269
|
+
}
|
|
270
|
+
const target = path.join(staging, ...name.split('/'))
|
|
271
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
272
|
+
fs.writeFileSync(target, data)
|
|
273
|
+
}
|
|
274
|
+
const root = locateSkillRoot(staging)
|
|
275
|
+
if (fs.existsSync(resolved)) {
|
|
276
|
+
fs.renameSync(resolved, backup)
|
|
277
|
+
moved = true
|
|
278
|
+
}
|
|
279
|
+
fs.renameSync(root, resolved)
|
|
280
|
+
fs.rmSync(backup, { recursive: true, force: true })
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (moved && !fs.existsSync(resolved) && fs.existsSync(backup)) {
|
|
283
|
+
fs.renameSync(backup, resolved)
|
|
284
|
+
}
|
|
285
|
+
throw error
|
|
286
|
+
} finally {
|
|
287
|
+
fs.rmSync(staging, { recursive: true, force: true })
|
|
288
|
+
if (fs.existsSync(resolved)) {
|
|
289
|
+
fs.rmSync(backup, { recursive: true, force: true })
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function downloadBuffer(url, redirects = 0, headers = {}) {
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
const lib = url.startsWith('https:') ? https : http
|
|
297
|
+
const request = lib.get(url, { timeout: 60_000, headers }, response => {
|
|
298
|
+
const status = response.statusCode ?? 0
|
|
299
|
+
if (status >= 300 && status < 400 && response.headers.location) {
|
|
300
|
+
response.resume()
|
|
301
|
+
if (redirects >= 5) {
|
|
302
|
+
reject(new Error('重定向过多'))
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
resolve(downloadBuffer(new URL(response.headers.location, url).href, redirects + 1, headers))
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
if (status !== 200) {
|
|
309
|
+
response.resume()
|
|
310
|
+
reject(new Error(`下载失败 ${status}`))
|
|
311
|
+
return
|
|
312
|
+
}
|
|
313
|
+
const chunks = []
|
|
314
|
+
response.on('data', chunk => chunks.push(chunk))
|
|
315
|
+
response.on('end', () => resolve(Buffer.concat(chunks)))
|
|
316
|
+
})
|
|
317
|
+
request.on('timeout', () => request.destroy(new Error('下载超时')))
|
|
318
|
+
request.on('error', reject)
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function checkUpdate(options, deps = {}) {
|
|
323
|
+
if (process.env.TIK_HOST_MANAGED_SKILLS === '1') {
|
|
324
|
+
return {
|
|
325
|
+
ok: true,
|
|
326
|
+
skill: options.skill,
|
|
327
|
+
current: null,
|
|
328
|
+
latest: null,
|
|
329
|
+
outdated: false,
|
|
330
|
+
cliMinVersion: '',
|
|
331
|
+
managed: true,
|
|
332
|
+
applied: false,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
const skillDir = options.skillDir
|
|
336
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md')
|
|
337
|
+
const currentMd = fs.existsSync(skillMdPath) ? fs.readFileSync(skillMdPath, 'utf8') : ''
|
|
338
|
+
const metadata = readSkillMetadata(currentMd)
|
|
339
|
+
if (isLocalDevSkill(skillDir, metadata)) {
|
|
340
|
+
return {
|
|
341
|
+
ok: true,
|
|
342
|
+
skill: options.skill,
|
|
343
|
+
current: metadata.version || null,
|
|
344
|
+
latest: metadata.version || null,
|
|
345
|
+
outdated: false,
|
|
346
|
+
cliMinVersion: metadata.cliMinVersion || '',
|
|
347
|
+
managed: true,
|
|
348
|
+
dev: true,
|
|
349
|
+
applied: false,
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const target = releaseTarget(metadata, options)
|
|
353
|
+
const headers = registryHeaders(target.registry, deps.env || process.env)
|
|
354
|
+
const fetchJson = deps.fetchJson ?? (async (url, requestHeaders) => JSON.parse((await downloadBuffer(url, 0, requestHeaders)).toString('utf8')))
|
|
355
|
+
const download = deps.download ?? ((url, requestHeaders) => downloadBuffer(url, 0, requestHeaders))
|
|
356
|
+
const packumentUrl = `${target.registry}/${encodeURIComponent(packageName(options.skill))}`
|
|
357
|
+
let packument
|
|
358
|
+
try {
|
|
359
|
+
packument = await fetchJson(packumentUrl, headers)
|
|
360
|
+
} catch (error) {
|
|
361
|
+
if (error instanceof Error && /下载失败 404/.test(error.message)) {
|
|
362
|
+
throw new Error(`制品库没有 ${packageName(options.skill)}`)
|
|
363
|
+
}
|
|
364
|
+
throw error
|
|
365
|
+
}
|
|
366
|
+
const remoteVersion = packument?.['dist-tags']?.[target.distTag]
|
|
367
|
+
if (!remoteVersion) {
|
|
368
|
+
throw new Error(`制品库没有 ${target.distTag} 标签`)
|
|
369
|
+
}
|
|
370
|
+
assertTagVersion(target.distTag, remoteVersion)
|
|
371
|
+
const versionMeta = packument?.versions?.[remoteVersion]
|
|
372
|
+
if (!versionMeta?.dist) {
|
|
373
|
+
throw new Error(`制品库没有版本 ${remoteVersion}`)
|
|
374
|
+
}
|
|
375
|
+
const current = metadata.version
|
|
376
|
+
const outdated = current !== remoteVersion
|
|
377
|
+
let applied = false
|
|
378
|
+
if (options.apply && outdated) {
|
|
379
|
+
const archive = await download(versionMeta.dist.tarball, headers)
|
|
380
|
+
verifyDist(archive, versionMeta.dist)
|
|
381
|
+
replaceSkillDir(skillDir, untar(archive))
|
|
382
|
+
applied = true
|
|
383
|
+
}
|
|
384
|
+
const installedMd = fs.existsSync(skillMdPath) ? fs.readFileSync(skillMdPath, 'utf8') : currentMd
|
|
385
|
+
const cliMinVersion = readSkillMetadata(installedMd).cliMinVersion || ''
|
|
386
|
+
return {
|
|
387
|
+
ok: true,
|
|
388
|
+
skill: options.skill,
|
|
389
|
+
current: current || null,
|
|
390
|
+
latest: remoteVersion,
|
|
391
|
+
outdated: applied ? false : outdated,
|
|
392
|
+
cliMinVersion,
|
|
393
|
+
applied,
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function parseArgs(argv) {
|
|
398
|
+
const args = { apply: false }
|
|
399
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
400
|
+
const arg = argv[index]
|
|
401
|
+
if (arg === '--apply') {
|
|
402
|
+
args.apply = true
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
405
|
+
const value = argv[index + 1]
|
|
406
|
+
if (!arg.startsWith('--') || !value || value.startsWith('--')) {
|
|
407
|
+
throw new Error(`参数不完整:${arg}`)
|
|
408
|
+
}
|
|
409
|
+
const key = arg.slice(2)
|
|
410
|
+
if (!['skill', 'skill-dir'].includes(key)) {
|
|
411
|
+
throw new Error(`未知参数 ${arg}`)
|
|
412
|
+
}
|
|
413
|
+
args[key === 'skill-dir' ? 'skillDir' : key] = value
|
|
414
|
+
index += 1
|
|
415
|
+
}
|
|
416
|
+
if (!args.skill || !args.skillDir) {
|
|
417
|
+
throw new Error('用法:node check-update.js --skill <名称> --skill-dir <目录> [--apply]')
|
|
418
|
+
}
|
|
419
|
+
return args
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function isDirectRun() {
|
|
423
|
+
const entry = process.argv[1]
|
|
424
|
+
if (!entry) {
|
|
425
|
+
return false
|
|
426
|
+
}
|
|
427
|
+
return import.meta.url === pathToFileURL(path.resolve(entry)).href
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (isDirectRun()) {
|
|
431
|
+
try {
|
|
432
|
+
const result = await checkUpdate(parseArgs(process.argv.slice(2)))
|
|
433
|
+
console.log(JSON.stringify(result))
|
|
434
|
+
} catch (error) {
|
|
435
|
+
console.error(error instanceof Error ? error.message : error)
|
|
436
|
+
process.exit(1)
|
|
437
|
+
}
|
|
438
|
+
}
|
package/README.md
DELETED