@ninkch/dsh-artcraft 0.2.6 → 0.2.8
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/README.md +27 -24
- package/README.zh-CN.md +22 -21
- package/artcraft.config.json.example +7 -4
- package/client.js +11 -2
- package/index.js +274 -105
- package/package.json +2 -1
- package/recipes/recipes.json +143 -0
package/README.md
CHANGED
|
@@ -25,13 +25,13 @@ This plugin brings that surface into DeepSeek Harness, so an AI agent can *opera
|
|
|
25
25
|
not just talk about them:
|
|
26
26
|
|
|
27
27
|
- **In chat** — four agent tools let any Harness session check the toolchain, run CLI commands,
|
|
28
|
-
send control-channel commands to a running app, and
|
|
28
|
+
send control-channel commands to a running app, and do bridged file operations.
|
|
29
29
|
- **In the browser** — a same-origin HTTP API (`/artcraft/api/*`) that any Harness web UI can call,
|
|
30
30
|
no CORS, no keys. It was built for [无限画布 / infinite-canvas](../dsh-infinite_canvas/README.md),
|
|
31
31
|
a node-based AI short-drama storyboard canvas, but any front end can use it.
|
|
32
|
-
- **
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
- **Paths belong to the caller** — this plugin is a bridge and owns no data directory. Files read
|
|
33
|
+
or written go wherever the caller points: chat invocations default to the current conversation
|
|
34
|
+
workspace (or any path named in the prompt); the canvas writes under its own project folder.
|
|
35
35
|
|
|
36
36
|
A typical AI short-drama loop looks like this: generate storyboard frames with a model →
|
|
37
37
|
batch color-grade and sharpen them in PhotoCraft → render an animated title in EffectCraft →
|
|
@@ -55,7 +55,8 @@ Every step after "generate" runs through this plugin, headless.
|
|
|
55
55
|
headless edits cut · grade · mix Lottie export
|
|
56
56
|
└───────────────────────┴───────────────────────┘
|
|
57
57
|
▼
|
|
58
|
-
|
|
58
|
+
caller-decided locations (chat → conversation
|
|
59
|
+
workspace · canvas → its project folder)
|
|
59
60
|
```
|
|
60
61
|
|
|
61
62
|
Everything runs locally. No cloud round-trips, no accounts, no telemetry.
|
|
@@ -102,19 +103,19 @@ pnpm install --dir C:/Users/<you>/.dsh/profiles/desktop
|
|
|
102
103
|
> If your Harness build ships a plugin manager UI that installs from a folder, that works too —
|
|
103
104
|
> it ends up writing exactly the two lines above.
|
|
104
105
|
|
|
105
|
-
**3. Configure** `artcraft.config.json`
|
|
106
|
-
`artcraft.config.json.example
|
|
107
|
-
|
|
106
|
+
**3. Configure** `artcraft.config.json` (copy from
|
|
107
|
+
`artcraft.config.json.example`). Recommended location: `C:\Users\<you>\.dsh\artcraft.config.json`
|
|
108
|
+
(persists across plugin updates); placing it next to `index.js` also works (legacy). The file is
|
|
109
|
+
re-read on every request — no restart needed for path changes:
|
|
108
110
|
|
|
109
111
|
```jsonc
|
|
110
112
|
{
|
|
111
|
-
"dataRoot": "D:/craft-workspace", // where assets & renders live
|
|
112
113
|
"bins": {
|
|
113
114
|
// three accepted forms: a directory (the <name>-cli binary is appended),
|
|
114
115
|
// a full path to the executable, or a bare name looked up on PATH
|
|
115
116
|
"photocraft": "C:/craft/photocraft-0.6.0-windows-x64-portable",
|
|
116
117
|
"filmcraft": "C:/craft/filmcraft-0.5.0-windows-x64-portable",
|
|
117
|
-
"effectcraft": "C:/
|
|
118
|
+
"effectcraft": "C:/Program Files/EffectCraft"
|
|
118
119
|
},
|
|
119
120
|
"control": { "filmcraft": 9876, "effectcraft": 9877 }
|
|
120
121
|
}
|
|
@@ -139,10 +140,10 @@ artcraft_cli { bin:"photocraft", args:["batch","--actions","demo/craft/grade.js
|
|
|
139
140
|
|
|
140
141
|
| Tool | What it does |
|
|
141
142
|
|---|---|
|
|
142
|
-
| `artcraft_status` | Toolchain health check: which CLIs resolved,
|
|
143
|
+
| `artcraft_status` | Toolchain health check: which CLIs resolved, config location, control ports, running jobs |
|
|
143
144
|
| `artcraft_cli` | Run any CLI subcommand synchronously (`photocraft-cli run/batch`, `filmcraft-cli commands`, …) |
|
|
144
145
|
| `artcraft_control` | One command over the JSON control channel of a *running* FilmCraft / EffectCraft |
|
|
145
|
-
| `artcraft_fs` | list / stat / read / write / mkdir
|
|
146
|
+
| `artcraft_fs` | Bridged file ops — list / stat / read / write / mkdir at caller-decided absolute paths |
|
|
146
147
|
|
|
147
148
|
## Driving it from the browser
|
|
148
149
|
|
|
@@ -170,15 +171,16 @@ Full route list:
|
|
|
170
171
|
| Route | Purpose |
|
|
171
172
|
|---|---|
|
|
172
173
|
| `GET /artcraft/api/status` | Toolchain health (same payload as `artcraft_status`) |
|
|
173
|
-
| `
|
|
174
|
-
| `
|
|
175
|
-
| `GET /artcraft/api/
|
|
176
|
-
| `GET /artcraft/api/file?rel=…` | Download a file — streamed, up to 2 GB |
|
|
174
|
+
| `POST /artcraft/api/fs?action=write&path=…` | Write a file — absolute `path`, raw body (Blob upload) or JSON `{base, rel, text\|base64}` |
|
|
175
|
+
| `GET /artcraft/api/fs?op=list\|stat\|readtext&path=…` | Read directories, metadata, small text files |
|
|
176
|
+
| `GET /artcraft/api/file?path=…` | Download a file — streamed, up to 2 GB |
|
|
177
177
|
| `POST /artcraft/api/job` · `GET ?id=…` · `POST /artcraft/api/job/cancel` | Job lifecycle with output statting and tail logs |
|
|
178
178
|
| `POST /artcraft/api/control` | Synchronous control-channel call |
|
|
179
179
|
|
|
180
180
|
Uploads are capped at 320 MB per request (Blob direct, no base64 needed for the raw path);
|
|
181
|
-
downloads stream up to 2 GB.
|
|
181
|
+
downloads stream up to 2 GB. All path arguments are **absolute** (or `base` absolute directory +
|
|
182
|
+
relative `rel`) — the caller decides where files live: chat calls point at the conversation
|
|
183
|
+
workspace, the canvas points at its project folder.
|
|
182
184
|
|
|
183
185
|
## The control channel: drive a running app
|
|
184
186
|
|
|
@@ -259,14 +261,15 @@ a web UI.
|
|
|
259
261
|
loopback-only and unauthenticated, so nothing else will answer on that port.
|
|
260
262
|
- **A job hangs** — `GET /artcraft/api/job` lists everything with logs; cancel by id. Plugin
|
|
261
263
|
reloads kill all child jobs by design.
|
|
262
|
-
- **
|
|
264
|
+
- **"需要绝对路径" errors** — pass absolute paths (chat: the conversation workspace; canvas: the
|
|
265
|
+
project folder), or a `base` absolute directory + relative `rel`.
|
|
263
266
|
|
|
264
267
|
## Security & trust model
|
|
265
268
|
|
|
266
269
|
The HTTP surface has **no authentication** and rides on the Harness web server, which listens on
|
|
267
270
|
loopback only — treat the port as trusted-local and don't expose it to a network; put a reverse
|
|
268
|
-
proxy with auth in front if you must. Child processes are locked to the
|
|
269
|
-
|
|
271
|
+
proxy with auth in front if you must. Child processes are locked to the seven allow-listed
|
|
272
|
+
family CLIs; the control channel only ever dials `127.0.0.1`.
|
|
270
273
|
|
|
271
274
|
## Publishing
|
|
272
275
|
|
|
@@ -281,10 +284,10 @@ GitHub: this repository is self-contained; `README.md` is English-first with a
|
|
|
281
284
|
[简体中文](README.zh-CN.md) companion.
|
|
282
285
|
|
|
283
286
|
**Releases run through npm Trusted Publishing** (GitHub Actions OIDC — no tokens, no OTP):
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
land on `dev`, get tested there, and are only merged to `main` and
|
|
287
|
+
publishing is triggered by **creating a GitHub Release** (tag `vX.Y.Z` matching `package.json`);
|
|
288
|
+
`.github/workflows/publish.yml` verifies the release commit is on `main` and the versions align,
|
|
289
|
+
then publishes. Release flow and rules live in [RELEASING.md](RELEASING.md) — in short: changes
|
|
290
|
+
land on `dev`, get tested there, and are only merged to `main` and released with the maintainer's
|
|
288
291
|
explicit approval.
|
|
289
292
|
|
|
290
293
|
## License
|
package/README.zh-CN.md
CHANGED
|
@@ -24,12 +24,12 @@ Effects)等。它们有一个大多数桌面软件都不具备的共同基因
|
|
|
24
24
|
本插件把这套自动化面接进 DeepSeek Harness,让 AI 不只是"谈论"这些软件,而是真的**操作**它们:
|
|
25
25
|
|
|
26
26
|
- **对话里** —— 四个 Agent 工具,任何 Harness 会话都能检查工具链、执行 CLI 命令、向运行中的
|
|
27
|
-
App
|
|
27
|
+
App 发控制通道命令、做桥接文件操作。
|
|
28
28
|
- **浏览器里** —— 同源 HTTP API(`/artcraft/api/*`),任何 Harness 前端都能直接调用:无 CORS、
|
|
29
29
|
无密钥。它为 [无限画布 / infinite-canvas](../dsh-infinite_canvas/README.md)(节点式 AI 短剧
|
|
30
30
|
分镜画布)而建,但任何前端都可用。
|
|
31
|
-
-
|
|
32
|
-
|
|
31
|
+
- **路径由调用方决定** —— 本插件只是桥接,不持有数据目录:对话调用默认落在当前对话工作空间
|
|
32
|
+
(或提示词指定的任意位置);画布调用则写在自己项目目录下。
|
|
33
33
|
|
|
34
34
|
典型的 AI 短剧闭环:模型生成分镜图 → PhotoCraft 批量调色锐化 → EffectCraft 渲染动态片头 →
|
|
35
35
|
FilmCraft 剪辑、调色、混音、烧字幕 → 导出 H.264。"生成"之后的每一步都由本插件无头驱动。
|
|
@@ -51,7 +51,7 @@ FilmCraft 剪辑、调色、混音、烧字幕 → 导出 H.264。"生成"之后
|
|
|
51
51
|
(无头) 混音导出 Lottie 导出
|
|
52
52
|
└───────────────────────┴───────────────────────┘
|
|
53
53
|
▼
|
|
54
|
-
|
|
54
|
+
产物位置由调用方决定(对话 → 当前工作空间 · 画布 → 项目目录)
|
|
55
55
|
```
|
|
56
56
|
|
|
57
57
|
全流程本地运行:不联网、无账号、无遥测。
|
|
@@ -96,17 +96,17 @@ pnpm install --dir C:/Users/<你>/.dsh/profiles/desktop
|
|
|
96
96
|
|
|
97
97
|
> 若你的 Harness 版本带有"从文件夹安装插件"的管理界面,用它也行 —— 最终落盘的就是上面两行。
|
|
98
98
|
|
|
99
|
-
**3. 配置** `
|
|
100
|
-
|
|
99
|
+
**3. 配置** `artcraft.config.json`(从 `artcraft.config.json.example` 复制)。推荐放
|
|
100
|
+
`C:\Users\<你>\.dsh\artcraft.config.json`(持久,更新插件不丢);放在 `index.js` 旁也可(旧版兼容)。
|
|
101
|
+
文件已 gitignore,且每次请求都会重读 —— 改路径无需重启:
|
|
101
102
|
|
|
102
103
|
```jsonc
|
|
103
104
|
{
|
|
104
|
-
"dataRoot": "D:/craft-workspace", // 素材与产物的根目录
|
|
105
105
|
"bins": {
|
|
106
106
|
// 三种写法:目录(自动拼接 <名>-cli 可执行文件)、完整 exe 路径、或走 PATH 的裸命令名
|
|
107
107
|
"photocraft": "C:/craft/photocraft-0.6.0-windows-x64-portable",
|
|
108
108
|
"filmcraft": "C:/craft/filmcraft-0.5.0-windows-x64-portable",
|
|
109
|
-
"effectcraft": "C:/
|
|
109
|
+
"effectcraft": "C:/Program Files/EffectCraft"
|
|
110
110
|
},
|
|
111
111
|
"control": { "filmcraft": 9876, "effectcraft": 9877 }
|
|
112
112
|
}
|
|
@@ -129,10 +129,10 @@ artcraft_cli { bin:"photocraft", args:["batch","--actions","demo/craft/grade.js
|
|
|
129
129
|
|
|
130
130
|
| 工具 | 用途 |
|
|
131
131
|
|---|---|
|
|
132
|
-
| `artcraft_status` | 工具体检:CLI
|
|
132
|
+
| `artcraft_status` | 工具体检:CLI 是否找到、配置目录、控制端口、进行中的任务 |
|
|
133
133
|
| `artcraft_cli` | 同步执行任意 CLI 子命令(`photocraft-cli run/batch`、`filmcraft-cli commands` 等) |
|
|
134
134
|
| `artcraft_control` | 向**运行中**的 FilmCraft / EffectCraft 控制通道发一条命令 |
|
|
135
|
-
| `artcraft_fs` |
|
|
135
|
+
| `artcraft_fs` | 桥接文件操作 —— list / stat / 读 / 写 / 建目录,路径由调用方绝对指定 |
|
|
136
136
|
|
|
137
137
|
## 在浏览器里驱动
|
|
138
138
|
|
|
@@ -159,15 +159,15 @@ POST /artcraft/api/job/cancel { "id":"7f2c…" }
|
|
|
159
159
|
| 路由 | 用途 |
|
|
160
160
|
|---|---|
|
|
161
161
|
| `GET /artcraft/api/status` | 工具体检(与 `artcraft_status` 同数据) |
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `GET /artcraft/api/
|
|
165
|
-
| `GET /artcraft/api/file?rel=…` | 下载文件 —— 流式,上限 2 GB |
|
|
162
|
+
| `POST /artcraft/api/fs?action=write&path=…` | 写文件 —— 绝对 `path`,原始字节流(Blob 直传)或 JSON `{base, rel, text\|base64}` |
|
|
163
|
+
| `GET /artcraft/api/fs?op=list\|stat\|readtext&path=…` | 读目录、元数据、小文本 |
|
|
164
|
+
| `GET /artcraft/api/file?path=…` | 下载文件 —— 流式,上限 2 GB |
|
|
166
165
|
| `POST /artcraft/api/job` · `GET ?id=…` · `POST /artcraft/api/job/cancel` | 任务生命周期,含产物探测与日志尾部 |
|
|
167
166
|
| `POST /artcraft/api/control` | 控制通道同步调用 |
|
|
168
167
|
|
|
169
168
|
上传单次上限 320 MB(原始路径 Blob 直传,无需 base64);下载流式上限 2 GB。
|
|
170
|
-
|
|
169
|
+
所有路径参数为**绝对路径**(或 `base` 绝对目录 + 相对 `rel`)—— 位置由调用方决定:
|
|
170
|
+
对话调用指向当前工作空间,画布调用指向其项目目录。
|
|
171
171
|
|
|
172
172
|
## 控制通道:驱动正在运行的 App
|
|
173
173
|
|
|
@@ -244,13 +244,14 @@ NVENC 硬编)、ProRes、DNxHR,附 EBU R128 响度归一与字幕烧录。
|
|
|
244
244
|
不会有别的应答者。
|
|
245
245
|
- **任务卡住** —— `GET /artcraft/api/job` 带日志列出全部任务,按 id 取消;插件重载会按设计
|
|
246
246
|
终止所有子任务。
|
|
247
|
-
-
|
|
247
|
+
- **"需要绝对路径"报错** —— 传绝对路径即可:对话用当前工作空间下的路径,画布用项目目录下的
|
|
248
|
+
路径(或 `base` 绝对目录 + 相对 `rel`)。
|
|
248
249
|
|
|
249
250
|
## 安全与信任模型
|
|
250
251
|
|
|
251
252
|
HTTP 面无鉴权,挂在 Harness Web 服务之下(仅回环监听)—— 把端口当本机可信处理,**不要暴露
|
|
252
|
-
|
|
253
|
-
`
|
|
253
|
+
到网络**;确需远程请加带鉴权的反向代理。子进程锁定 7 个白名单家族 CLI;控制通道只会拨
|
|
254
|
+
`127.0.0.1`。
|
|
254
255
|
|
|
255
256
|
## 发布
|
|
256
257
|
|
|
@@ -264,9 +265,9 @@ GitHub 上传本仓库即可:`README.md` 英文优先,附
|
|
|
264
265
|
[简体中文](README.zh-CN.md) 对照。
|
|
265
266
|
|
|
266
267
|
**发版走 npm Trusted Publishing**(GitHub Actions OIDC —— 无令牌、无 OTP):
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
[RELEASING.md](RELEASING.md):改动先进 `dev` 测试,维护者明确批准后才合并 main
|
|
268
|
+
发布由 **GitHub Release** 触发(tag `vX.Y.Z` 与 package.json version 一致);`.github/workflows/publish.yml`
|
|
269
|
+
会校验发布提交在 main 上且版本对齐,然后自动发布。发版流程与红线见
|
|
270
|
+
[RELEASING.md](RELEASING.md):改动先进 `dev` 测试,维护者明确批准后才合并 main 并发 Release。
|
|
270
271
|
|
|
271
272
|
## 许可
|
|
272
273
|
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"dataRoot": "D:/craft-workspace",
|
|
3
2
|
"bins": {
|
|
4
|
-
"photocraft": "
|
|
5
|
-
"
|
|
6
|
-
"
|
|
3
|
+
"photocraft": "D:/craft/photocraft-0.6.0/photocraft-0.6.0-windows-x64-portable",
|
|
4
|
+
"vectorcraft": "C:/Program Files/VectorCraft",
|
|
5
|
+
"filmcraft": "D:/craft/filmcraft-0.5.0/filmcraft-0.5.0-windows-x64-portable",
|
|
6
|
+
"lightcraft": "C:/Program Files/LightCraft",
|
|
7
|
+
"pdfcraft": "C:/Program Files/PdfCraft",
|
|
8
|
+
"effectcraft": "C:/Program Files/EffectCraft",
|
|
9
|
+
"designcraft": "C:/Program Files/DesignCraft"
|
|
7
10
|
},
|
|
8
11
|
"control": {
|
|
9
12
|
"filmcraft": 9876,
|
package/client.js
CHANGED
|
@@ -62,10 +62,19 @@ window.__ModuleLoader__.load({
|
|
|
62
62
|
useEffect(() => () => { alive.current = false; }, []);
|
|
63
63
|
|
|
64
64
|
const loadStatus = useCallback(async () => {
|
|
65
|
+
setBusy('refresh');
|
|
65
66
|
try {
|
|
67
|
+
// 刷新 = 状态 + 顺带一次家族安装探测(后装软件/改安装目录后自动修正配置;
|
|
68
|
+
// 探测端点在旧版插件上不存在时静默跳过)
|
|
69
|
+
try {
|
|
70
|
+
await api('/discover', {
|
|
71
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}',
|
|
72
|
+
});
|
|
73
|
+
} catch { /* 旧版无此路由 → 静默跳过 */ }
|
|
66
74
|
const s = await api('/status');
|
|
67
75
|
if (alive.current) { setStatus(s); setStatusErr(null); }
|
|
68
76
|
} catch (e) { if (alive.current) setStatusErr(String(e.message || e)); }
|
|
77
|
+
finally { if (alive.current) setBusy(''); }
|
|
69
78
|
}, []);
|
|
70
79
|
|
|
71
80
|
const checkUpdate = useCallback(async () => {
|
|
@@ -120,8 +129,8 @@ window.__ModuleLoader__.load({
|
|
|
120
129
|
statusErr
|
|
121
130
|
? h('div', { className: 'dshac-muted' }, '未连接:' + statusErr + '(插件可能未注册或 Harness 需要重启)')
|
|
122
131
|
: h('div', null, Object.keys(LABELS).map(binRow),
|
|
123
|
-
status?.
|
|
124
|
-
'
|
|
132
|
+
status?.config?.path ? h('div', { className: 'dshac-row dshac-muted' },
|
|
133
|
+
'配置目录:', h('span', { className: 'dshac-mono' }, status.config.path)) : null),
|
|
125
134
|
h('div', { className: 'dshac-row', style: { marginTop: '10px' } },
|
|
126
135
|
h('span', { className: 'dshac-muted' },
|
|
127
136
|
'当前 v' + (status?.version ?? upd?.current ?? '?') +
|
package/index.js
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @ninkch/dsh-artcraft — DeepSeek Harness 插件(Host 半侧)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 本插件只是【桥接】:把 ArtCraft 家族软件的自动化接口转接给 Harness,不持有任何数据目录。
|
|
5
|
+
* 结果文件的位置由调用方决定 ——
|
|
6
|
+
* 对话调用:当前对话工作空间下的绝对路径(或提示词指定的任意位置)
|
|
7
|
+
* 画布调用:画布项目目录下的绝对路径(哪个项目使用就放哪个项目下)
|
|
8
|
+
*
|
|
9
|
+
* 两种形态:
|
|
5
10
|
* 1) Agent Tools(对话内直接驱动):
|
|
6
|
-
* artcraft_status ——
|
|
7
|
-
* artcraft_cli —— 运行
|
|
11
|
+
* artcraft_status —— 检查 7 个家族 CLI 是否可用、配置目录、控制通道配置
|
|
12
|
+
* artcraft_cli —— 运行 7 个家族 CLI 任意命令(cwd / 文件参数由调用方给绝对路径)
|
|
8
13
|
* artcraft_control —— 向运行中的 FilmCraft/EffectCraft JSON 控制通道发一条命令
|
|
9
|
-
* artcraft_fs ——
|
|
14
|
+
* artcraft_fs —— 桥接文件操作(list/stat/readtext/write/mkdir,绝对路径)
|
|
15
|
+
* artcraft_update —— 插件自身更新(检查/应用)
|
|
10
16
|
* 2) HTTP API(同源 /artcraft/api/*,供无限画布等前端调用,无 CORS 问题):
|
|
11
17
|
* GET /artcraft/api/status 环境自检
|
|
12
|
-
* GET /artcraft/api/projects 列出 dataRoot 下的项目目录
|
|
13
18
|
* GET /artcraft/api/fs list / stat / readtext
|
|
14
19
|
* POST /artcraft/api/fs write(JSON base64/text 或原始字节流)
|
|
15
20
|
* GET /artcraft/api/file 下载产物(流式,raw 或 text)
|
|
@@ -19,12 +24,10 @@
|
|
|
19
24
|
* POST /artcraft/api/control 控制通道同步调用(engine.execute / ui.*)
|
|
20
25
|
*
|
|
21
26
|
* 安全约定:
|
|
22
|
-
* -
|
|
23
|
-
* - 子进程只允许三个白名单键(photocraft/filmcraft/effectcraft),
|
|
24
|
-
* 具体可执行文件由 artcraft.config.json 指定;
|
|
27
|
+
* - 子进程只允许 7 个白名单键(家族 CLI),具体可执行文件由 artcraft.config.json 指定;
|
|
25
28
|
* - 控制通道只连 127.0.0.1,与 ArtCraft 官方协议一致(App 需以 --control 启动;
|
|
26
29
|
* PhotoCraft 的控制通道为鉴权模式,本插件不代理,故仅支持 filmcraft/effectcraft);
|
|
27
|
-
* - 本插件与 infinite-canvas 零代码耦合,仅通过 HTTP +
|
|
30
|
+
* - 本插件与 infinite-canvas 零代码耦合,仅通过 HTTP + 调用方给定路径协作。
|
|
28
31
|
*
|
|
29
32
|
* 信任模型:HTTP API 无鉴权,随 DSH Web GUI 仅监听回环;不要把 GUI 端口暴露到局域网。
|
|
30
33
|
*/
|
|
@@ -39,7 +42,7 @@ import crypto from 'node:crypto';
|
|
|
39
42
|
import { fileURLToPath } from 'node:url';
|
|
40
43
|
|
|
41
44
|
const NAME = 'artcraft';
|
|
42
|
-
const VERSION = '0.2.
|
|
45
|
+
const VERSION = '0.2.8';
|
|
43
46
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
44
47
|
|
|
45
48
|
const BINS = ['photocraft', 'vectorcraft', 'filmcraft', 'lightcraft', 'pdfcraft', 'effectcraft', 'designcraft'];
|
|
@@ -55,7 +58,6 @@ const LINE_CAP = 4000; // 单行截断
|
|
|
55
58
|
const KEEP_JOBS = 64; // 已结束任务保留条数
|
|
56
59
|
|
|
57
60
|
const DEFAULT_CONFIG = {
|
|
58
|
-
dataRoot: path.join(os.homedir(), 'craft-workspace'),
|
|
59
61
|
bins: {
|
|
60
62
|
photocraft: 'photocraft-cli',
|
|
61
63
|
vectorcraft: 'vectorcraft-cli',
|
|
@@ -68,13 +70,19 @@ const DEFAULT_CONFIG = {
|
|
|
68
70
|
control: { ...DEFAULT_PORTS },
|
|
69
71
|
};
|
|
70
72
|
|
|
73
|
+
const APP_DISPLAY = {
|
|
74
|
+
photocraft: 'PhotoCraft', vectorcraft: 'VectorCraft', filmcraft: 'FilmCraft',
|
|
75
|
+
lightcraft: 'LightCraft', pdfcraft: 'PdfCraft', effectcraft: 'EffectCraft', designcraft: 'DesignCraft',
|
|
76
|
+
};
|
|
77
|
+
|
|
71
78
|
/* ============================ 配置 ============================ */
|
|
72
79
|
|
|
73
80
|
async function loadConfig() {
|
|
74
81
|
const cfg = {
|
|
75
|
-
dataRoot: DEFAULT_CONFIG.dataRoot,
|
|
76
82
|
bins: { ...DEFAULT_CONFIG.bins },
|
|
77
83
|
control: { ...DEFAULT_PORTS },
|
|
84
|
+
configPath: null, // 实际生效的配置文件(供界面显示"配置目录")
|
|
85
|
+
autoDiscovered: false, // 首次运行是否由自动探测生成了配置
|
|
78
86
|
};
|
|
79
87
|
// 配置查找顺序:~/.dsh(持久,推荐 —— 更新替换包目录也不丢)→ 包目录(旧版兼容)
|
|
80
88
|
const candidates = [
|
|
@@ -84,7 +92,6 @@ async function loadConfig() {
|
|
|
84
92
|
for (const candidate of candidates) {
|
|
85
93
|
try {
|
|
86
94
|
const user = JSON.parse(await fs.readFile(candidate, 'utf8'));
|
|
87
|
-
if (user && typeof user.dataRoot === 'string' && user.dataRoot) cfg.dataRoot = user.dataRoot;
|
|
88
95
|
if (user && user.bins) {
|
|
89
96
|
for (const k of BINS) {
|
|
90
97
|
if (typeof user.bins[k] === 'string' && user.bins[k]) cfg.bins[k] = user.bins[k];
|
|
@@ -96,28 +103,82 @@ async function loadConfig() {
|
|
|
96
103
|
if (Number.isInteger(p) && p > 0 && p < 65536) cfg.control[k] = p;
|
|
97
104
|
}
|
|
98
105
|
}
|
|
106
|
+
cfg.configPath = candidate;
|
|
99
107
|
break; // 第一个可解析的配置生效
|
|
100
108
|
} catch { /* 该候选不存在/非法 → 试下一个 */ }
|
|
101
109
|
}
|
|
110
|
+
// 首次运行(没有任何配置文件):自动探测本机家族软件安装位置并生成持久配置
|
|
111
|
+
if (!cfg.configPath) {
|
|
112
|
+
try {
|
|
113
|
+
const found = await discoverBinPaths();
|
|
114
|
+
const target = path.join(os.homedir(), '.dsh', 'artcraft.config.json');
|
|
115
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
116
|
+
await fs.writeFile(target, JSON.stringify({ bins: { ...cfg.bins, ...found }, control: { ...cfg.control } }, null, 2) + '\n');
|
|
117
|
+
for (const k of Object.keys(found)) cfg.bins[k] = found[k];
|
|
118
|
+
cfg.configPath = target;
|
|
119
|
+
cfg.autoDiscovered = Object.keys(found).length > 0;
|
|
120
|
+
} catch { /* 自动探测失败不影响默认行为 */ }
|
|
121
|
+
}
|
|
102
122
|
return cfg;
|
|
103
123
|
}
|
|
104
124
|
|
|
125
|
+
/** 自动探测:扫描常见安装位置(Program Files 等)里的家族 CLI */
|
|
126
|
+
async function discoverBinPaths() {
|
|
127
|
+
const isWin = process.platform === 'win32';
|
|
128
|
+
const pf = process.env['ProgramFiles'] || (isWin ? 'C:\\Program Files' : '/usr/local/bin');
|
|
129
|
+
const pf86 = process.env['ProgramFiles(x86)'] || '';
|
|
130
|
+
const lap = process.env['LOCALAPPDATA']
|
|
131
|
+
? path.join(process.env['LOCALAPPDATA'], 'Programs')
|
|
132
|
+
: path.join(os.homedir(), '.local');
|
|
133
|
+
const roots = [pf, ...(pf86 ? [pf86] : []), lap];
|
|
134
|
+
const found = {};
|
|
135
|
+
for (const key of BINS) {
|
|
136
|
+
const cliName = cliNameFor(key);
|
|
137
|
+
const names = [APP_DISPLAY[key], key, key.toUpperCase(), key.toLowerCase()];
|
|
138
|
+
outer: for (const root of roots) {
|
|
139
|
+
for (const name of names) {
|
|
140
|
+
const exe = path.join(root, name, cliName);
|
|
141
|
+
try {
|
|
142
|
+
if ((await fs.stat(exe)).isFile()) { found[key] = path.join(root, name); break outer; }
|
|
143
|
+
} catch { /* 继续 */ }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return found;
|
|
148
|
+
}
|
|
149
|
+
|
|
105
150
|
/* ============================ 路径与进程 ============================ */
|
|
106
151
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
152
|
+
/**
|
|
153
|
+
* 桥接语义:路径由调用方决定(本插件只是桥梁,不设数据目录)——
|
|
154
|
+
* 对话调用:当前对话工作空间下的绝对路径(或提示词指定的任意位置)
|
|
155
|
+
* 画布调用:画布项目目录下的绝对路径
|
|
156
|
+
* 支持 ~ 展开;也接受 base(绝对目录) + rel(相对) 组合,方便批量调用。
|
|
157
|
+
*/
|
|
158
|
+
function resolveCallerPath(p) {
|
|
159
|
+
let s = String(p ?? '').trim();
|
|
160
|
+
if (!s) throw new Error('缺少路径参数');
|
|
161
|
+
if (s === '~' || s.startsWith('~/') || s.startsWith('~\\')) s = path.join(os.homedir(), s.slice(1));
|
|
162
|
+
const abs = path.resolve(s);
|
|
163
|
+
if (!path.isAbsolute(abs)) {
|
|
164
|
+
throw new Error('需要绝对路径 —— 对话调用传当前工作空间下的绝对路径,画布传项目目录下的绝对路径(或用 base + 相对路径组合)');
|
|
113
165
|
}
|
|
114
|
-
return
|
|
166
|
+
return abs;
|
|
115
167
|
}
|
|
116
168
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
169
|
+
function resolveBaseRel(base, rel) {
|
|
170
|
+
if (base) {
|
|
171
|
+
const b = resolveCallerPath(base);
|
|
172
|
+
if (rel == null || String(rel) === '') return b;
|
|
173
|
+
return path.resolve(b, String(rel));
|
|
174
|
+
}
|
|
175
|
+
return resolveCallerPath(rel);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function ensureDir(p) {
|
|
179
|
+
const abs = resolveCallerPath(p);
|
|
180
|
+
await fs.mkdir(abs, { recursive: true });
|
|
181
|
+
return abs;
|
|
121
182
|
}
|
|
122
183
|
|
|
123
184
|
function cliNameFor(key) {
|
|
@@ -266,15 +327,27 @@ function controlCall(port, method, params, { timeoutMs = 30_000 } = {}) {
|
|
|
266
327
|
|
|
267
328
|
const jobs = new Map(); // id -> job
|
|
268
329
|
|
|
269
|
-
|
|
330
|
+
/**
|
|
331
|
+
* 桥接语义:产物路径由调用方给定 ——
|
|
332
|
+
* 绝对路径直接使用;也可传 cwd(绝对目录)让 outputs 以它为基准。
|
|
333
|
+
*/
|
|
334
|
+
async function statOutputs(outputs, cwd) {
|
|
270
335
|
const out = [];
|
|
336
|
+
const base = cwd ? resolveCallerPath(cwd) : null;
|
|
271
337
|
for (const item of outputs ?? []) {
|
|
272
|
-
const
|
|
338
|
+
const spec = String(item.rel ?? item.path ?? item);
|
|
339
|
+
let abs;
|
|
273
340
|
try {
|
|
274
|
-
|
|
275
|
-
out.push({ rel, exists: true, dir: st.isDirectory(), size: st.size, mtime: st.mtimeMs });
|
|
341
|
+
abs = base ? path.resolve(base, spec) : resolveCallerPath(spec);
|
|
276
342
|
} catch {
|
|
277
|
-
out.push({ rel, exists: false, dir: false, size: null, mtime: null });
|
|
343
|
+
out.push({ rel: spec, exists: false, dir: false, size: null, mtime: null });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
const st = await fs.stat(abs);
|
|
348
|
+
out.push({ rel: spec, path: abs, exists: true, dir: st.isDirectory(), size: st.size, mtime: st.mtimeMs });
|
|
349
|
+
} catch {
|
|
350
|
+
out.push({ rel: spec, path: abs, exists: false, dir: false, size: null, mtime: null });
|
|
278
351
|
}
|
|
279
352
|
}
|
|
280
353
|
return out;
|
|
@@ -309,18 +382,19 @@ function pruneJobs() {
|
|
|
309
382
|
}
|
|
310
383
|
}
|
|
311
384
|
|
|
312
|
-
async function startJob(cfg, { bin, args,
|
|
385
|
+
async function startJob(cfg, { bin, args, cwd, outputs, timeoutMs }) {
|
|
313
386
|
if (!BINS.includes(bin)) throw new Error(`bin 必须是 ${BINS.join(' | ')} 之一`);
|
|
314
387
|
if (args != null && !Array.isArray(args)) throw new Error('args 必须是字符串数组');
|
|
315
388
|
const exe = await resolveBin(cfg, bin);
|
|
316
|
-
|
|
389
|
+
// 桥接语义:cwd 由调用方给绝对路径(对话=工作空间下的目录,画布=项目目录);不传则继承 Host 进程
|
|
390
|
+
const cwdAbs = cwd ? await ensureDir(cwd) : null;
|
|
317
391
|
const id = crypto.randomUUID();
|
|
318
392
|
const job = {
|
|
319
393
|
id,
|
|
320
394
|
bin,
|
|
321
395
|
exe,
|
|
322
396
|
args: (args ?? []).map(String),
|
|
323
|
-
cwd,
|
|
397
|
+
cwd: cwdAbs,
|
|
324
398
|
status: 'running',
|
|
325
399
|
code: null,
|
|
326
400
|
signal: null,
|
|
@@ -328,11 +402,11 @@ async function startJob(cfg, { bin, args, cwdRel, outputs, timeoutMs }) {
|
|
|
328
402
|
endedAt: null,
|
|
329
403
|
stdout: [],
|
|
330
404
|
stderr: [],
|
|
331
|
-
outputs: (outputs ?? []).map((
|
|
405
|
+
outputs: (outputs ?? []).map((o) => ({ rel: String(o.rel ?? o.path ?? o) })),
|
|
332
406
|
child: null,
|
|
333
407
|
timer: null,
|
|
334
408
|
};
|
|
335
|
-
const child = spawn(job.exe, job.args, { cwd, windowsHide: true });
|
|
409
|
+
const child = spawn(job.exe, job.args, { cwd: cwdAbs ?? undefined, windowsHide: true });
|
|
336
410
|
job.child = child;
|
|
337
411
|
child.stdout?.setEncoding('utf8');
|
|
338
412
|
child.stderr?.setEncoding('utf8');
|
|
@@ -384,13 +458,13 @@ async function statusInfo(cfg, meta) {
|
|
|
384
458
|
}
|
|
385
459
|
bins[key] = { configured: cfg.bins[key], exe, found, note };
|
|
386
460
|
}
|
|
387
|
-
let
|
|
388
|
-
try {
|
|
461
|
+
let configPath = null;
|
|
462
|
+
try { configPath = cfg.configPath && (await fs.stat(cfg.configPath)).isFile() ? cfg.configPath : null; } catch { /* 不存在 */ }
|
|
389
463
|
return {
|
|
390
464
|
plugin: NAME,
|
|
391
465
|
version: VERSION,
|
|
392
466
|
platform: process.platform,
|
|
393
|
-
|
|
467
|
+
config: { path: configPath, found: !!configPath, autoDiscovered: !!cfg.autoDiscovered },
|
|
394
468
|
bins,
|
|
395
469
|
control: cfg.control,
|
|
396
470
|
jobs: {
|
|
@@ -502,17 +576,30 @@ async function detectInstall() {
|
|
|
502
576
|
return { mode: repoDir ? 'git' : 'npm', here, real, repoDir, profileDir, gitClean, branch };
|
|
503
577
|
}
|
|
504
578
|
|
|
579
|
+
const REGISTRIES = ['https://registry.npmjs.org', 'https://registry.npmmirror.com'];
|
|
580
|
+
|
|
505
581
|
async function fetchLatestVersion() {
|
|
506
|
-
const
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
582
|
+
const slug = PKG_NAME.replace('/', '%2f');
|
|
583
|
+
const errors = [];
|
|
584
|
+
// 回退链:官方源 → npmmirror 镜像(国内无代理机器也能完成检查与更新)
|
|
585
|
+
for (const reg of REGISTRIES) {
|
|
586
|
+
try {
|
|
587
|
+
const res = await fetch(`${reg}/${slug}/latest`, { signal: AbortSignal.timeout(12000) });
|
|
588
|
+
if (res.status === 404) { errors.push(`${reg}: 404`); continue; }
|
|
589
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
590
|
+
const j = await res.json();
|
|
591
|
+
if (!j.version) { errors.push(`${reg}: 无版本数据`); continue; }
|
|
592
|
+
return {
|
|
593
|
+
latest: j.version ?? null,
|
|
594
|
+
tarball: j.dist?.tarball ?? null,
|
|
595
|
+
integrity: j.dist?.integrity ?? null,
|
|
596
|
+
source: reg,
|
|
597
|
+
};
|
|
598
|
+
} catch (e) {
|
|
599
|
+
errors.push(`${reg}: ${String(e?.cause?.message || e?.message || e)}`);
|
|
600
|
+
}
|
|
515
601
|
}
|
|
602
|
+
return { latest: null, note: '查询失败(官方源与 npmmirror 均不可达)—— ' + errors.join(';') };
|
|
516
603
|
}
|
|
517
604
|
|
|
518
605
|
async function updateCheck() {
|
|
@@ -554,8 +641,20 @@ async function updateApply() {
|
|
|
554
641
|
await fs.mkdir(tmp, { recursive: true });
|
|
555
642
|
try {
|
|
556
643
|
const tgzPath = path.join(tmp, 'pkg.tgz');
|
|
557
|
-
|
|
558
|
-
|
|
644
|
+
// 下载:优先元数据自带的官方地址;失败时回退 npmmirror 镜像(sha512 校验保证内容一致)
|
|
645
|
+
let res;
|
|
646
|
+
try {
|
|
647
|
+
res = await fetch(meta.tarball, { signal: AbortSignal.timeout(180000) });
|
|
648
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
649
|
+
} catch (e) {
|
|
650
|
+
const slug = PKG_NAME.replace('/', '%2f');
|
|
651
|
+
const mirrorUrl = `https://registry.npmmirror.com/${slug}/-/${PKG_NAME.split('/')[1]}-${meta.latest}.tgz`;
|
|
652
|
+
WriteMirrorRetry: {
|
|
653
|
+
res = await fetch(mirrorUrl, { signal: AbortSignal.timeout(180000) });
|
|
654
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
655
|
+
break WriteMirrorRetry;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
559
658
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
560
659
|
if (buf.length > 64 * 1024 * 1024) throw new Error('tarball 超过 64MB 上限,已终止');
|
|
561
660
|
const digest = crypto.createHash('sha512').update(buf).digest('base64');
|
|
@@ -632,7 +731,7 @@ export function apply(ctx) {
|
|
|
632
731
|
|
|
633
732
|
attempt({
|
|
634
733
|
name: 'artcraft_status',
|
|
635
|
-
description: '检查 ArtCraft
|
|
734
|
+
description: '检查 ArtCraft 家族 7 个 CLI 是否可用,报告配置目录、控制通道端口与任务状态。本插件只是桥接,产物路径由调用方决定。',
|
|
636
735
|
schema: { type: 'object', properties: {}, additionalProperties: false },
|
|
637
736
|
async execute() {
|
|
638
737
|
const cfg = await loadConfig();
|
|
@@ -648,7 +747,7 @@ export function apply(ctx) {
|
|
|
648
747
|
properties: {
|
|
649
748
|
bin: { type: 'string', enum: [...BINS], description: '工具键' },
|
|
650
749
|
args: { type: 'array', items: { type: 'string' }, description: '命令行参数(不含程序名)' },
|
|
651
|
-
|
|
750
|
+
cwd: { type: 'string', description: '工作目录(绝对路径,可选):对话=当前工作空间下的目录,画布=项目目录;不传则继承 Host 进程' },
|
|
652
751
|
timeoutMs: { type: 'number', description: '超时毫秒,默认 900000' },
|
|
653
752
|
},
|
|
654
753
|
required: ['bin', 'args'],
|
|
@@ -658,7 +757,7 @@ export function apply(ctx) {
|
|
|
658
757
|
const cfg = await loadConfig();
|
|
659
758
|
if (args.args != null && !Array.isArray(args.args)) throw new Error('args 必须是字符串数组');
|
|
660
759
|
const exe = await resolveBin(cfg, String(args.bin));
|
|
661
|
-
const cwd = await
|
|
760
|
+
const cwd = args.cwd ? await ensureDir(args.cwd) : null;
|
|
662
761
|
const r = await runProcess(exe, args.args ?? [], { cwd, timeoutMs: args.timeoutMs });
|
|
663
762
|
return { bin: args.bin, exe, cwd, ...r };
|
|
664
763
|
},
|
|
@@ -689,56 +788,57 @@ export function apply(ctx) {
|
|
|
689
788
|
|
|
690
789
|
attempt({
|
|
691
790
|
name: 'artcraft_fs',
|
|
692
|
-
description: '
|
|
791
|
+
description: '桥接文件操作:list / stat / readtext / write / mkdir。路径由调用方决定 —— 对话调用传当前工作空间下的绝对路径,画布调用传项目目录下的绝对路径(或 base + rel 组合)。',
|
|
693
792
|
schema: {
|
|
694
793
|
type: 'object',
|
|
695
794
|
properties: {
|
|
696
795
|
op: { type: 'string', enum: ['list', 'stat', 'readtext', 'write', 'mkdir'] },
|
|
697
|
-
|
|
796
|
+
path: { type: 'string', description: '绝对路径(或 ~/ 展开)' },
|
|
797
|
+
base: { type: 'string', description: '可选:绝对基准目录,rel 相对它解析' },
|
|
798
|
+
rel: { type: 'string', description: '可选:相对 base 的路径(需同时给 base)' },
|
|
698
799
|
text: { type: 'string', description: 'op=write 时的文本内容' },
|
|
699
800
|
base64: { type: 'string', description: 'op=write 时的二进制内容(base64)' },
|
|
700
801
|
},
|
|
701
|
-
required: ['op'
|
|
802
|
+
required: ['op'],
|
|
702
803
|
additionalProperties: false,
|
|
703
804
|
},
|
|
704
805
|
async execute(args) {
|
|
705
|
-
const cfg = await loadConfig();
|
|
706
|
-
const rel = String(args.rel ?? '');
|
|
707
806
|
const op = String(args.op);
|
|
807
|
+
const P = () => resolveBaseRel(args.base, args.path ?? args.rel);
|
|
708
808
|
if (op === 'list') {
|
|
709
|
-
const dir =
|
|
809
|
+
const dir = P();
|
|
710
810
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
711
811
|
const out = [];
|
|
712
812
|
for (const ent of entries) {
|
|
713
813
|
const st = await fs.stat(path.join(dir, ent.name)).catch(() => null);
|
|
714
814
|
out.push({ name: ent.name, dir: ent.isDirectory(), size: st?.size ?? null, mtime: st?.mtimeMs ?? null });
|
|
715
815
|
}
|
|
716
|
-
return {
|
|
816
|
+
return { path: dir, entries: out };
|
|
717
817
|
}
|
|
718
818
|
if (op === 'stat') {
|
|
719
|
-
const st = await fs.stat(
|
|
720
|
-
return {
|
|
819
|
+
const st = await fs.stat(P());
|
|
820
|
+
return { path: P(), size: st.size, mtime: st.mtimeMs, dir: st.isDirectory() };
|
|
721
821
|
}
|
|
722
822
|
if (op === 'readtext') {
|
|
723
|
-
const p =
|
|
823
|
+
const p = P();
|
|
724
824
|
const st = await fs.stat(p);
|
|
725
825
|
if (st.size > TEXT_CAP) throw new Error('文件超过 16MB,请用 GET /artcraft/api/file 取回');
|
|
726
826
|
const buf = await fs.readFile(p);
|
|
727
|
-
return {
|
|
827
|
+
return { path: p, text: buf.toString('utf8') };
|
|
728
828
|
}
|
|
729
829
|
if (op === 'mkdir') {
|
|
730
|
-
const p = await
|
|
731
|
-
return {
|
|
830
|
+
const p = await ensureDir(P());
|
|
831
|
+
return { path: p };
|
|
732
832
|
}
|
|
733
833
|
if (op === 'write') {
|
|
734
|
-
const p =
|
|
834
|
+
const p = P();
|
|
735
835
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
736
836
|
let data;
|
|
737
837
|
if (typeof args.base64 === 'string') data = Buffer.from(args.base64, 'base64');
|
|
738
838
|
else if (typeof args.text === 'string') data = Buffer.from(args.text, 'utf8');
|
|
739
839
|
else throw new Error('write 需要 text 或 base64');
|
|
740
840
|
await fs.writeFile(p, data);
|
|
741
|
-
return {
|
|
841
|
+
return { path: p, size: data.length };
|
|
742
842
|
}
|
|
743
843
|
throw new Error(`未知 op: ${op}`);
|
|
744
844
|
},
|
|
@@ -746,7 +846,7 @@ export function apply(ctx) {
|
|
|
746
846
|
|
|
747
847
|
attempt({
|
|
748
848
|
name: 'artcraft_update',
|
|
749
|
-
description: '检查/应用 @ninkch/dsh-artcraft 插件自身的更新:action=check 对比本地与 npm latest;action=apply 执行更新(git pull
|
|
849
|
+
description: '检查/应用 @ninkch/dsh-artcraft 插件自身的更新:action=check 对比本地与 npm latest;action=apply 执行更新(git pull 或官方 tarball 直装 + sha512 校验,按安装形态自动选择),完成后需重启 Harness 生效。',
|
|
750
850
|
schema: {
|
|
751
851
|
type: 'object',
|
|
752
852
|
properties: {
|
|
@@ -761,6 +861,63 @@ export function apply(ctx) {
|
|
|
761
861
|
},
|
|
762
862
|
});
|
|
763
863
|
|
|
864
|
+
attempt({
|
|
865
|
+
name: 'artcraft_discover',
|
|
866
|
+
description: '自动探测本机 ArtCraft 家族软件安装位置(Program Files / LocalAppData\\Programs 等),写入/修复配置:填充默认空缺项,并自动纠正已失效的路径(改安装目录、卸载重装到别处);用户显式配置仍有效则不动。',
|
|
867
|
+
schema: { type: 'object', properties: {}, additionalProperties: false },
|
|
868
|
+
async execute() {
|
|
869
|
+
const cfg = await loadConfig();
|
|
870
|
+
const found = await discoverBinPaths();
|
|
871
|
+
const applied = {};
|
|
872
|
+
for (const k of BINS) {
|
|
873
|
+
if (!found[k]) continue;
|
|
874
|
+
let effective = null;
|
|
875
|
+
try { effective = await resolveBin(cfg, k); } catch { /* 未配置 */ }
|
|
876
|
+
const ok = effective ? await fileExists(effective) : false;
|
|
877
|
+
if (ok) continue; // 现有配置仍有效 → 不动
|
|
878
|
+
applied[k] = found[k];
|
|
879
|
+
}
|
|
880
|
+
if (!Object.keys(applied).length) {
|
|
881
|
+
return { applied: {}, configPath: cfg.configPath, note: '家族程序路径均已就绪,无需更新' };
|
|
882
|
+
}
|
|
883
|
+
const target = cfg.configPath || path.join(os.homedir(), '.dsh', 'artcraft.config.json');
|
|
884
|
+
let obj = {};
|
|
885
|
+
try { obj = JSON.parse(await fs.readFile(target, 'utf8')); } catch { /* 新文件 */ }
|
|
886
|
+
obj.bins = { ...(obj.bins ?? {}), ...applied };
|
|
887
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
888
|
+
await fs.writeFile(target, JSON.stringify(obj, null, 2) + '\n');
|
|
889
|
+
return { applied, configPath: target, note: '已写入配置,立即生效' };
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
attempt({
|
|
894
|
+
name: 'artcraft_recipes',
|
|
895
|
+
description: '内置专家处理方案库:ArtCraft 家族的专业工作流配方(短剧海报、分镜批量调色、横转竖、响度标准化、SRT 字幕烧录、动态片头、多平台分发包、先探测后验证纪律)。action=list 列出全部;action=get + id 取单个配方的专业原则、执行步骤与验证清单。',
|
|
896
|
+
schema: {
|
|
897
|
+
type: 'object',
|
|
898
|
+
properties: {
|
|
899
|
+
action: { type: 'string', enum: ['list', 'get'] },
|
|
900
|
+
id: { type: 'string', description: 'action=get 时的配方 id' },
|
|
901
|
+
},
|
|
902
|
+
required: ['action'],
|
|
903
|
+
additionalProperties: false,
|
|
904
|
+
},
|
|
905
|
+
async execute(args) {
|
|
906
|
+
const recipesPath = path.join(HERE, 'recipes', 'recipes.json');
|
|
907
|
+
const catalog = JSON.parse(await fs.readFile(recipesPath, 'utf8'));
|
|
908
|
+
if (String(args.action) === 'get') {
|
|
909
|
+
const r = catalog.recipes.find((x) => x.id === String(args.id ?? ''));
|
|
910
|
+
if (!r) throw new Error('配方不存在: ' + args.id + '(可用: ' + catalog.recipes.map((x) => x.id).join(', ') + ')');
|
|
911
|
+
return r;
|
|
912
|
+
}
|
|
913
|
+
return {
|
|
914
|
+
version: catalog.version,
|
|
915
|
+
recipes: catalog.recipes.map((r) => ({ id: r.id, title: r.title, apps: r.apps, goal: r.goal })),
|
|
916
|
+
hint: 'action=get + id 取完整配方(专业原则 + 执行步骤 + 验证清单)',
|
|
917
|
+
};
|
|
918
|
+
},
|
|
919
|
+
});
|
|
920
|
+
|
|
764
921
|
return () => {
|
|
765
922
|
for (const d of disposers) { try { d(); } catch { /* ignore */ } }
|
|
766
923
|
};
|
|
@@ -774,48 +931,34 @@ export function apply(ctx) {
|
|
|
774
931
|
sendJson(res, 200, await statusInfo(cfg, { registered: [...toolNames], errors: [...toolErrors] }));
|
|
775
932
|
}),
|
|
776
933
|
|
|
777
|
-
makeRoute(`/${NAME}/api/projects`, async (req, res) => {
|
|
778
|
-
const cfg = await loadConfig();
|
|
779
|
-
await fs.mkdir(cfg.dataRoot, { recursive: true });
|
|
780
|
-
const entries = await fs.readdir(cfg.dataRoot, { withFileTypes: true });
|
|
781
|
-
const projects = [];
|
|
782
|
-
for (const ent of entries) {
|
|
783
|
-
if (!ent.isDirectory()) continue;
|
|
784
|
-
const st = await fs.stat(path.join(cfg.dataRoot, ent.name)).catch(() => null);
|
|
785
|
-
projects.push({ name: ent.name, mtime: st?.mtimeMs ?? null });
|
|
786
|
-
}
|
|
787
|
-
projects.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
|
|
788
|
-
sendJson(res, 200, { dataRoot: cfg.dataRoot, projects });
|
|
789
|
-
}),
|
|
790
|
-
|
|
791
934
|
makeRoute(`/${NAME}/api/fs`, async (req, res) => {
|
|
792
|
-
const cfg = await loadConfig();
|
|
793
935
|
const url = new URL(req.url, 'http://x');
|
|
936
|
+
const base = url.searchParams.get('base'); // 可选:绝对基准目录(画布=项目目录,对话=工作空间下目录)
|
|
937
|
+
const P = () => resolveBaseRel(base, url.searchParams.get('path') ?? url.searchParams.get('rel'));
|
|
794
938
|
if (req.method === 'GET') {
|
|
795
939
|
const op = url.searchParams.get('op') ?? 'list';
|
|
796
|
-
const rel = url.searchParams.get('rel') ?? '.';
|
|
797
940
|
if (op === 'list') {
|
|
798
|
-
const dir =
|
|
941
|
+
const dir = P();
|
|
799
942
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
800
943
|
const out = [];
|
|
801
944
|
for (const ent of entries) {
|
|
802
945
|
const st = await fs.stat(path.join(dir, ent.name)).catch(() => null);
|
|
803
946
|
out.push({ name: ent.name, dir: ent.isDirectory(), size: st?.size ?? null, mtime: st?.mtimeMs ?? null });
|
|
804
947
|
}
|
|
805
|
-
sendJson(res, 200, {
|
|
948
|
+
sendJson(res, 200, { path: dir, entries: out });
|
|
806
949
|
return;
|
|
807
950
|
}
|
|
808
951
|
if (op === 'stat') {
|
|
809
|
-
const st = await fs.stat(
|
|
810
|
-
sendJson(res, 200, {
|
|
952
|
+
const st = await fs.stat(P());
|
|
953
|
+
sendJson(res, 200, { path: P(), size: st.size, mtime: st.mtimeMs, dir: st.isDirectory() });
|
|
811
954
|
return;
|
|
812
955
|
}
|
|
813
956
|
if (op === 'readtext') {
|
|
814
|
-
const p =
|
|
957
|
+
const p = P();
|
|
815
958
|
const st = await fs.stat(p);
|
|
816
959
|
if (st.size > TEXT_CAP) throw new Error('文件超过 16MB,请改用 GET /artcraft/api/file');
|
|
817
960
|
const buf = await fs.readFile(p);
|
|
818
|
-
sendJson(res, 200, {
|
|
961
|
+
sendJson(res, 200, { path: p, text: buf.toString('utf8') });
|
|
819
962
|
return;
|
|
820
963
|
}
|
|
821
964
|
throw new Error(`未知 op: ${op}`);
|
|
@@ -824,47 +967,43 @@ export function apply(ctx) {
|
|
|
824
967
|
const action = url.searchParams.get('action') ?? 'write';
|
|
825
968
|
if (action === 'mkdir') {
|
|
826
969
|
const body = await readJson(req);
|
|
827
|
-
const p = await
|
|
970
|
+
const p = await ensureDir(resolveBaseRel(body.base ?? url.searchParams.get('base'), body.path ?? body.rel ?? url.searchParams.get('rel')));
|
|
828
971
|
sendJson(res, 200, { ok: true, path: p });
|
|
829
972
|
return;
|
|
830
973
|
}
|
|
831
|
-
// write:JSON {rel, base64|text}
|
|
974
|
+
// write:JSON {path 或 base+rel, base64|text} 或原始字节流(路径走查询串)
|
|
832
975
|
const isJson = String(req.headers['content-type'] ?? '').includes('json');
|
|
833
|
-
let
|
|
976
|
+
let target;
|
|
834
977
|
let data;
|
|
835
978
|
if (isJson) {
|
|
836
979
|
const body = await readJson(req, WRITE_CAP);
|
|
837
|
-
rel = String(body.rel ?? url.searchParams.get('rel') ?? '');
|
|
838
980
|
if (typeof body.base64 === 'string') data = Buffer.from(body.base64.replace(/\s+/g, ''), 'base64');
|
|
839
981
|
else if (typeof body.text === 'string') data = Buffer.from(body.text, 'utf8');
|
|
840
982
|
else throw new Error('write 需要 base64 或 text 字段');
|
|
983
|
+
target = resolveBaseRel(body.base ?? url.searchParams.get('base'), body.path ?? body.rel ?? url.searchParams.get('rel'));
|
|
841
984
|
} else {
|
|
842
|
-
rel = String(url.searchParams.get('rel') ?? '');
|
|
843
985
|
data = await readBody(req, WRITE_CAP);
|
|
986
|
+
target = resolveBaseRel(url.searchParams.get('base'), url.searchParams.get('path') ?? url.searchParams.get('rel'));
|
|
844
987
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
await fs.writeFile(p, data);
|
|
849
|
-
sendJson(res, 200, { ok: true, rel, size: data.length });
|
|
988
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
989
|
+
await fs.writeFile(target, data);
|
|
990
|
+
sendJson(res, 200, { ok: true, path: target, size: data.length });
|
|
850
991
|
return;
|
|
851
992
|
}
|
|
852
993
|
throw new Error('仅支持 GET/POST');
|
|
853
994
|
}),
|
|
854
995
|
|
|
855
996
|
makeRoute(`/${NAME}/api/file`, async (req, res) => {
|
|
856
|
-
const cfg = await loadConfig();
|
|
857
997
|
if (req.method !== 'GET') throw new Error('仅支持 GET');
|
|
858
998
|
const url = new URL(req.url, 'http://x');
|
|
859
|
-
const
|
|
860
|
-
const p = resolveSafeRel(cfg.dataRoot, rel);
|
|
999
|
+
const p = resolveBaseRel(url.searchParams.get('base'), url.searchParams.get('path') ?? url.searchParams.get('rel'));
|
|
861
1000
|
const st = await fs.stat(p);
|
|
862
1001
|
if (!st.isFile()) throw new Error('不是常规文件');
|
|
863
1002
|
if (st.size > READ_CAP) throw new Error(`文件超过读取上限(${Math.round(READ_CAP / 1048576)}MB)`);
|
|
864
1003
|
if (url.searchParams.get('as') === 'text') {
|
|
865
1004
|
if (st.size > TEXT_CAP) throw new Error('文本模式上限 16MB,请直接下载原始文件');
|
|
866
1005
|
const buf = await fs.readFile(p);
|
|
867
|
-
sendJson(res, 200, {
|
|
1006
|
+
sendJson(res, 200, { path: p, text: buf.toString('utf8') });
|
|
868
1007
|
return;
|
|
869
1008
|
}
|
|
870
1009
|
// 流式传输:产物可能是几百 MB 的视频,不整读进内存
|
|
@@ -882,13 +1021,13 @@ export function apply(ctx) {
|
|
|
882
1021
|
if (id) {
|
|
883
1022
|
const job = jobs.get(id);
|
|
884
1023
|
if (!job) throw new Error('任务不存在或已被回收');
|
|
885
|
-
job.outputs = await statOutputs(
|
|
1024
|
+
job.outputs = await statOutputs(job.outputs, job.cwd);
|
|
886
1025
|
sendJson(res, 200, jobSnapshot(job, tail));
|
|
887
1026
|
return;
|
|
888
1027
|
}
|
|
889
1028
|
const list = [];
|
|
890
1029
|
for (const job of [...jobs.values()].reverse()) {
|
|
891
|
-
job.outputs = await statOutputs(
|
|
1030
|
+
job.outputs = await statOutputs(job.outputs, job.cwd);
|
|
892
1031
|
list.push(jobSnapshot(job, 5));
|
|
893
1032
|
}
|
|
894
1033
|
sendJson(res, 200, { jobs: list });
|
|
@@ -925,6 +1064,36 @@ export function apply(ctx) {
|
|
|
925
1064
|
sendJson(res, 200, await updateApply());
|
|
926
1065
|
}),
|
|
927
1066
|
|
|
1067
|
+
makeRoute(`/${NAME}/api/discover`, async (req, res) => {
|
|
1068
|
+
if (req.method !== 'POST') throw new Error('仅支持 POST');
|
|
1069
|
+
const cfg = await loadConfig();
|
|
1070
|
+
const found = await discoverBinPaths();
|
|
1071
|
+
// 只填充默认空缺项;显式配置仅在已失效(改安装目录/卸载重装)时自动纠正,有效则不动
|
|
1072
|
+
const applied = {};
|
|
1073
|
+
for (const k of BINS) {
|
|
1074
|
+
if (!found[k]) continue;
|
|
1075
|
+
let effective = null;
|
|
1076
|
+
try { effective = await resolveBin(cfg, k); } catch { /* 未配置 */ }
|
|
1077
|
+
const ok = effective ? await fileExists(effective) : false;
|
|
1078
|
+
if (ok) continue;
|
|
1079
|
+
applied[k] = found[k];
|
|
1080
|
+
}
|
|
1081
|
+
if (Object.keys(applied).length) {
|
|
1082
|
+
const target = cfg.configPath || path.join(os.homedir(), '.dsh', 'artcraft.config.json');
|
|
1083
|
+
let obj = {};
|
|
1084
|
+
try { obj = JSON.parse(await fs.readFile(target, 'utf8')); } catch { /* 新文件 */ }
|
|
1085
|
+
obj.bins = { ...(obj.bins ?? {}), ...applied };
|
|
1086
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
1087
|
+
await fs.writeFile(target, JSON.stringify(obj, null, 2) + '\n');
|
|
1088
|
+
}
|
|
1089
|
+
sendJson(res, 200, {
|
|
1090
|
+
discovered: found,
|
|
1091
|
+
applied,
|
|
1092
|
+
configPath: cfg.configPath,
|
|
1093
|
+
note: Object.keys(applied).length ? '已探测并写入配置,立即生效' : '没有需要更新的家族程序',
|
|
1094
|
+
});
|
|
1095
|
+
}),
|
|
1096
|
+
|
|
928
1097
|
makeRoute(`/${NAME}/api/control`, async (req, res) => {
|
|
929
1098
|
if (req.method !== 'POST') throw new Error('仅支持 POST');
|
|
930
1099
|
const cfg = await loadConfig();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ninkch/dsh-artcraft",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Drive the open-source ArtCraft studio apps (PhotoCraft / FilmCraft / EffectCraft) from DeepSeek Harness ? agent tools, a same-origin HTTP API, and plugins-page status with self-update.",
|
|
6
6
|
"keywords": [
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"client.js",
|
|
36
36
|
"cordis.patch.yml",
|
|
37
37
|
"artcraft.config.json.example",
|
|
38
|
+
"recipes",
|
|
38
39
|
"README.md",
|
|
39
40
|
"README.zh-CN.md",
|
|
40
41
|
"NOTICE.md"
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"updated": "2026-10-10",
|
|
4
|
+
"recipes": [
|
|
5
|
+
{
|
|
6
|
+
"id": "poster-short-drama",
|
|
7
|
+
"title": "短剧海报(竖版)",
|
|
8
|
+
"apps": ["photocraft"],
|
|
9
|
+
"goal": "生成竖版短剧海报:标题层级、装饰元素、无损输出",
|
|
10
|
+
"principles": [
|
|
11
|
+
"视觉层级:主标题 → 副标题 → 装饰元素,字号/字重至少差 2 级,同屏不超过 3 个信息层",
|
|
12
|
+
"对比度:文字与背景亮度差 ≥ 4.5:1,浅背景加深色字,深背景加浅色字,或加投影/描边兜底",
|
|
13
|
+
"安全边距:四周留 5% 不放关键内容(平台裁切友好)",
|
|
14
|
+
"中文字体显式指定(如 Noto Sans SC),不要依赖默认字体(缺 CJK 字形会变方框)",
|
|
15
|
+
"HTTP 传中文参数一律走 UTF-8 字节体,避免编码损坏"
|
|
16
|
+
],
|
|
17
|
+
"steps": [
|
|
18
|
+
"photocraft-cli run --new '{\"width\":1080,\"height\":1440}' --cmd <背景填充/渐变命令> --out <工作空间>/poster.png",
|
|
19
|
+
"标题:Noto Sans SC,主标题字号 ≥ 120,副标题 ≤ 48;装饰条用像素层绘制",
|
|
20
|
+
"逐层 --cmd 叠加(每条 --params 作用于前一个 --cmd),最后 --out 输出 PNG 无损"
|
|
21
|
+
],
|
|
22
|
+
"checks": ["取回产物做视觉检查:字形完整、层级清晰、四边安全区无关键内容"]
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"id": "storyboard-color-grade",
|
|
26
|
+
"title": "分镜批量调色(一致性)",
|
|
27
|
+
"apps": ["photocraft"],
|
|
28
|
+
"goal": "整批分镜图统一色调,保持跨镜一致性",
|
|
29
|
+
"principles": [
|
|
30
|
+
"先定基调再微调:一条柔和 S 曲线 + 少量 vibrance,避免逐张各调各的",
|
|
31
|
+
"保护肤色:调整幅度宁小勿大,人脸偏色比不够风格化更伤观感",
|
|
32
|
+
"锐化放在调色之后(amount 60-80),避免锐化放大噪点",
|
|
33
|
+
"actions.json 一处定义、整批复用,保证批次内完全一致"
|
|
34
|
+
],
|
|
35
|
+
"steps": [
|
|
36
|
+
"写 craft/grade.json:[{\"cmd\":\"layer.newAdjustmentLayer.curves\",\"params\":{\"points\":[[0,0],[64,48],[192,212],[255,255]]}},{\"cmd\":\"filter.sharpen.smartSharpen\",\"params\":{\"amount\":80}}]",
|
|
37
|
+
"photocraft-cli batch --actions <项目>/craft/grade.json --in <项目>/media --out <项目>/out/graded",
|
|
38
|
+
"抽 2-3 张对比原图检查,不满意只调曲线中段(64,48 / 192,212 两点)重跑"
|
|
39
|
+
],
|
|
40
|
+
"checks": ["抽样对比:同场景相邻分镜色调一致;高光不过曝、暗部不死黑"]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "vertical-reframe",
|
|
44
|
+
"title": "横转竖重构图",
|
|
45
|
+
"apps": ["filmcraft", "photocraft"],
|
|
46
|
+
"goal": "16:9 素材适配 9:16 竖屏,三种策略按画面主体选择",
|
|
47
|
+
"principles": [
|
|
48
|
+
"策略选择:主体居中 → 居中裁切;主体偏侧 → 位移裁切(保主体);多主体/全景 → 模糊填充(上原图下补景,不裁掉信息)",
|
|
49
|
+
"人脸安全:裁切框上缘留在头顶上方 10%,下缘到胸口",
|
|
50
|
+
"分辨率对齐目标平台:抖音/快手/视频号 1080×1920@30fps"
|
|
51
|
+
],
|
|
52
|
+
"steps": [
|
|
53
|
+
"filmcraft-cli probe <素材> 先量分辨率/时长/帧率(永远先探测再动手)",
|
|
54
|
+
"序列设 1080×1920@30,放置后按位移/缩放保留主体",
|
|
55
|
+
"导出 H.264:--quality 80 起步"
|
|
56
|
+
],
|
|
57
|
+
"checks": ["导出后 probe 验证分辨率/时长;抽帧目检主体完整、不切脸"]
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"id": "loudness-web",
|
|
61
|
+
"title": "网络平台响度标准化",
|
|
62
|
+
"apps": ["filmcraft"],
|
|
63
|
+
"goal": "成片响度对齐平台标准,避免用户手动调音量",
|
|
64
|
+
"principles": [
|
|
65
|
+
"主流短视频平台目标:integrated -14 LUFS,true peak ≤ -1 dBTP",
|
|
66
|
+
"导出时一次完成(file.exportMedia loudnessLufs 参数),不要二次压限",
|
|
67
|
+
"BGM 与人声分层:人声为主轨,BGM 侧链闪避 6-10 dB"
|
|
68
|
+
],
|
|
69
|
+
"steps": [
|
|
70
|
+
"exec file.exportMedia {\"path\":<out>,\"format\":\"h264\",\"loudnessLufs\":-14,\"wait\":true}",
|
|
71
|
+
"probe 产物核对时长/编码/音轨"
|
|
72
|
+
],
|
|
73
|
+
"checks": ["probe 报告音轨存在且响度参数已应用;多平台分发时每平台独立导出"]
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"id": "srt-burn",
|
|
77
|
+
"title": "字幕烧录(SRT)",
|
|
78
|
+
"apps": ["filmcraft"],
|
|
79
|
+
"goal": "SRT 字幕导入并烧录进成片",
|
|
80
|
+
"principles": [
|
|
81
|
+
"单行 ≤ 15 个中文字符,超长拆条;每条最短停留 1 秒",
|
|
82
|
+
"位置:底部安全区,避开平台 UI(进度条/互动栏约占底部 15%)",
|
|
83
|
+
"样式统一:描边 2-3px 或半透明底条,全片一种字体"
|
|
84
|
+
],
|
|
85
|
+
"steps": [
|
|
86
|
+
"exec captions.import {\"path\":<srt 绝对路径>}",
|
|
87
|
+
"可选 exec captions.setStyle {\"font\":…,\"size\":…,\"outline\":…}",
|
|
88
|
+
"exec file.exportMedia {\"path\":<out>,\"format\":\"h264\",\"burnCaptions\":true,\"wait\":true}"
|
|
89
|
+
],
|
|
90
|
+
"checks": ["抽帧目检字幕:无溢出、无遮挡主体、时间轴同步"]
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"id": "title-motion",
|
|
94
|
+
"title": "动态片头/花字(EffectCraft)",
|
|
95
|
+
"apps": ["effectcraft"],
|
|
96
|
+
"goal": "生成动态片头,叠加回剪辑或直接使用",
|
|
97
|
+
"principles": [
|
|
98
|
+
".ecproj 是版本化 JSON —— 直接生成或模板替换文本,比逐帧描述高效",
|
|
99
|
+
"动画时长 1.5-3s:入场 0.3-0.5s 缓动(ease),停留 ≥ 1s,出场 0.3s",
|
|
100
|
+
"双出口:MP4 直用;或导出 Lottie 嵌网页预览;带 alpha 的 ProRes 4444 可叠进 FilmCraft 当图形层"
|
|
101
|
+
],
|
|
102
|
+
"steps": [
|
|
103
|
+
"生成/填充 intro.ecproj(composition 尺寸对齐主序列)",
|
|
104
|
+
"effectcraft-cli render --comp Main --out <项目>/out/intro.mp4 <项目>/craft/intro.ecproj",
|
|
105
|
+
"或 exec file.exportLottie 导出 .json 给网页端播放"
|
|
106
|
+
],
|
|
107
|
+
"checks": ["render 后 probe 时长/分辨率;视觉检查动画节奏(入场不突兀、停留可读完整标题)"]
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"id": "delivery-pack",
|
|
111
|
+
"title": "多平台分发包",
|
|
112
|
+
"apps": ["filmcraft"],
|
|
113
|
+
"goal": "同一成片产出多平台版本(分辨率/帧率/响度/命名规范)",
|
|
114
|
+
"principles": [
|
|
115
|
+
"平台参数表:抖音/快手/视频号 1080×1920@30 -14LUFS;B 站/西瓜 1920×1080@30 -14LUFS",
|
|
116
|
+
"命名规范:<项目>_<平台>_v<序号>.mp4,一次导出成组不散落",
|
|
117
|
+
"每平台独立导出(各自过 loudness 与 probe),不要一份文件多处转发"
|
|
118
|
+
],
|
|
119
|
+
"steps": [
|
|
120
|
+
"逐平台 exec file.exportMedia {\"path\":<out>,\"format\":\"h264\",\"width\":…,\"height\":…,\"loudnessLufs\":-14,\"wait\":true}",
|
|
121
|
+
"每个产物跑一次 probe 核对"
|
|
122
|
+
],
|
|
123
|
+
"checks": ["probe 全部产物:分辨率/帧率/音轨符合平台表;文件命名一致"]
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
"id": "probe-verify",
|
|
127
|
+
"title": "通用纪律:先探测、后验证",
|
|
128
|
+
"apps": ["filmcraft", "photocraft", "effectcraft"],
|
|
129
|
+
"goal": "所有媒体操作的通用工作流纪律(来自专业剪辑技能的通用实践)",
|
|
130
|
+
"principles": [
|
|
131
|
+
"先探测再动手:永远先 probe 素材拿真实时长/分辨率/帧率/音轨,不从文件名猜",
|
|
132
|
+
"能无损不重编:剪切片段优先流复制,只在必须(精确剪切/滤镜/换格式)时重编码",
|
|
133
|
+
"做完必验证:产物 probe 一次 + 关键帧目检,报告里写明验证结果",
|
|
134
|
+
"诚实标注:成功是 Done、失败是 Failed,部分完成必须写出缺口"
|
|
135
|
+
],
|
|
136
|
+
"steps": [
|
|
137
|
+
"任何处理前:filmcraft-cli probe <media>",
|
|
138
|
+
"处理后:filmcraft-cli probe <out> + 抽帧目检"
|
|
139
|
+
],
|
|
140
|
+
"checks": ["probe 输出与预期一致;差异写入报告而不是含糊带过"]
|
|
141
|
+
}
|
|
142
|
+
]
|
|
143
|
+
}
|