@uzooai/anime-ai-cli 0.0.0-stage → 0.5.0

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.
Files changed (123) hide show
  1. package/.agents/skills/uzoo-anime-cli/SKILL.md +83 -0
  2. package/.agents/skills/uzoo-anime-cli/references/agreement-workflow.md +51 -0
  3. package/.agents/skills/uzoo-anime-cli/references/canvas-workflow.md +53 -0
  4. package/.agents/skills/uzoo-anime-cli/references/commands.md +250 -0
  5. package/.agents/skills/uzoo-anime-cli/references/generation-workflow.md +152 -0
  6. package/.agents/skills/uzoo-anime-cli/references/image-tools-workflow.md +175 -0
  7. package/.agents/skills/uzoo-anime-cli/references/interaction-workflow.md +67 -0
  8. package/.agents/skills/uzoo-anime-cli/references/material-graph-workflow.md +123 -0
  9. package/.agents/skills/uzoo-anime-cli/references/node-capabilities.md +95 -0
  10. package/.agents/skills/uzoo-anime-cli/references/node-workflow.md +94 -0
  11. package/.agents/skills/uzoo-anime-cli/references/remake-workflow.md +54 -0
  12. package/.agents/skills/uzoo-anime-cli/references/repaint-workflow.md +300 -0
  13. package/.agents/skills/uzoo-anime-cli/references/seedance-review-workflow.md +41 -0
  14. package/.agents/skills/uzoo-anime-cli/references/video-tools-workflow.md +71 -0
  15. package/CHANGELOG.md +92 -0
  16. package/LICENSE +21 -0
  17. package/README.md +242 -3
  18. package/assets/README.md +4 -0
  19. package/assets/favicon-uzoo.png +0 -0
  20. package/assets/logo-uzoo.svg +7 -0
  21. package/docs/CLI/345/275/223/345/211/215/350/214/203/345/233/264/344/270/216/346/234/254/345/234/260/346/211/223/345/214/205-20261103.md +134 -0
  22. package/docs/CLI/346/216/210/346/235/203/346/216/245/345/217/243/344/270/216/344/273/244/347/211/214/350/207/252/345/212/250/345/210/267/346/226/260/350/256/276/350/256/241.md +540 -0
  23. package/docs/CLI/347/274/272/345/217/243/346/240/270/345/257/271/344/270/216/346/224/266/345/260/276-20261105.md +65 -0
  24. package/docs/CLI/347/275/221/351/241/265/344/277/235/345/255/230/350/212/202/347/202/271/347/274/272/345/244/261/344/272/244/346/216/245.md +52 -0
  25. package/docs/M0-M5/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +89 -0
  26. package/docs/npm/347/272/277/344/270/212/345/217/221/345/270/203/344/270/216/345/256/211/350/243/205/344/275/277/347/224/250.md +151 -0
  27. package/docs//344/270/213/350/275/275/346/216/245/345/217/243/351/200/202/351/205/215/344/270/216/351/252/214/346/224/266.md +107 -0
  28. package/docs//344/270/232/345/212/241/350/203/275/345/212/233/350/241/245/345/205/250/344/270/216Skill/347/273/223/346/236/204/350/247/204/345/210/222.md +311 -0
  29. package/docs//345/205/250/345/261/200/351/231/204/344/273/266/350/265/204/346/272/220/345/234/260/345/235/200/344/270/216/346/250/241/345/236/213/350/257/273/345/217/226/345/267/256/345/274/202.md +47 -0
  30. package/docs//345/206/205/351/203/250Windows/345/256/211/350/243/205/344/270/216/345/215/207/347/272/247/351/252/214/346/224/266/346/270/205/345/215/225.md +34 -0
  31. package/docs//345/233/276/347/211/207/346/212/240/345/233/276/346/216/245/345/205/245/344/270/216/351/252/214/346/224/266-20261104.md +110 -0
  32. package/docs//345/274/200/345/217/221/350/256/241/345/210/222.md +127 -0
  33. package/docs//345/274/202/346/255/245/346/212/240/345/233/276CLI/346/235/203/351/231/220/344/272/244/346/216/245-20261105.md +50 -0
  34. package/docs//345/274/202/346/255/245/346/212/240/345/233/276/347/234/237/345/256/236/351/252/214/346/224/266-20261105.md +44 -0
  35. package/docs//346/226/260/344/274/232/350/257/235/344/273/273/345/212/241/344/272/244/344/273/243-/351/230/266/346/256/2652A.md +79 -0
  36. package/docs//346/226/260/351/203/250/347/275/262/347/224/273/345/270/203/345/206/262/347/252/201/351/252/214/346/224/266-20261102.md +61 -0
  37. package/docs//346/231/256/351/200/232/346/265/217/350/247/210/345/231/250/347/231/273/345/275/225.md +53 -0
  38. package/docs//346/255/243/345/274/217/347/253/231/345/210/207/346/215/242/344/270/216/351/252/214/346/224/266-20261104.md +90 -0
  39. package/docs//347/224/273/345/270/203CLI/344/270/213/344/270/200/351/230/266/346/256/265/345/274/200/345/217/221/350/256/241/345/210/222.md +136 -0
  40. package/docs//347/224/273/345/270/203CLI/351/235/236/350/275/254/347/273/230/345/256/214/345/226/204/350/256/241/345/210/222.md +183 -0
  41. package/docs//347/224/273/345/270/203/350/203/275/345/212/233/345/245/221/347/272/246/346/240/270/345/257/271.md +59 -0
  42. package/docs//347/234/237/345/256/236/344/270/232/345/212/241/346/216/245/345/205/245/344/270/216/346/265/213/350/257/225.md +1004 -0
  43. package/docs//347/264/240/346/235/220/345/205/245/345/272/223/344/270/216/347/224/273/345/270/203/344/277/235/345/255/230/351/227/256/351/242/230/345/256/232/344/275/215.md +52 -0
  44. package/docs//351/230/266/346/256/2653B/351/252/214/346/224/266/344/272/244/346/216/245.md +44 -0
  45. package/docs//351/230/266/346/256/265/344/270/200/351/252/214/346/224/266.md +60 -0
  46. package/docs//351/235/236/350/275/254/347/273/230/346/214/201/347/273/255/345/256/214/345/226/204/344/270/216/351/252/214/346/224/266-20261103.md +76 -0
  47. package/docs//351/235/236/350/275/254/347/273/230/347/254/254/344/272/214/346/211/271/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +39 -0
  48. package/docs//351/235/236/350/275/254/347/273/230/350/241/245/345/205/205/346/235/241/344/273/266/351/252/214/346/224/266-20261103.md +113 -0
  49. package/docs//351/235/236/350/275/254/347/273/230/351/246/226/346/211/271/345/256/236/346/226/275/344/270/216/351/252/214/346/224/266.md +71 -0
  50. package/examples/matting.json +5 -0
  51. package/examples/real-business/image-request.template.json +7 -0
  52. package/examples/real-business/node-patch.template.json +11 -0
  53. package/extensions/uzoo-cli-login/manifest.json +9 -0
  54. package/extensions/uzoo-cli-login/popup.css +1 -0
  55. package/extensions/uzoo-cli-login/popup.html +14 -0
  56. package/extensions/uzoo-cli-login/popup.js +65 -0
  57. package/package.json +56 -6
  58. package/scripts/check-package-resources.mjs +56 -0
  59. package/scripts/check-release.mjs +19 -0
  60. package/scripts/pack-local.mjs +33 -0
  61. package/scripts/release-policy.mjs +22 -0
  62. package/src/agreement-page.js +62 -0
  63. package/src/agreement.js +419 -0
  64. package/src/annotation-media.js +386 -0
  65. package/src/api.js +168 -0
  66. package/src/browser-login.js +13 -0
  67. package/src/callback-page.js +44 -0
  68. package/src/canvas.js +105 -0
  69. package/src/cli-authorization.js +122 -0
  70. package/src/cli.js +459 -0
  71. package/src/download-name.js +17 -0
  72. package/src/draft-finalize.js +229 -0
  73. package/src/generation-graph.js +124 -0
  74. package/src/generation-input.js +472 -0
  75. package/src/graph-disconnect.js +89 -0
  76. package/src/graph.js +431 -0
  77. package/src/group-edit.js +53 -0
  78. package/src/group-layout.js +113 -0
  79. package/src/group-membership.js +126 -0
  80. package/src/history-add.js +119 -0
  81. package/src/image-annotation.js +246 -0
  82. package/src/image-crop.js +283 -0
  83. package/src/image-matting.js +82 -0
  84. package/src/image-media.js +89 -0
  85. package/src/image-output.js +385 -0
  86. package/src/local-output.js +23 -0
  87. package/src/material-download.js +55 -0
  88. package/src/material-save.js +254 -0
  89. package/src/materials.js +147 -0
  90. package/src/matting-background.js +73 -0
  91. package/src/matting-jobs.js +63 -0
  92. package/src/matting-worker.js +49 -0
  93. package/src/matting.js +170 -0
  94. package/src/media-copy.js +53 -0
  95. package/src/media.js +343 -0
  96. package/src/model-options.js +76 -0
  97. package/src/multipart.js +28 -0
  98. package/src/node-copy.js +95 -0
  99. package/src/node-delete.js +49 -0
  100. package/src/node-history.js +231 -0
  101. package/src/normal-login.js +118 -0
  102. package/src/package-info.js +8 -0
  103. package/src/png-alpha.js +60 -0
  104. package/src/protocol.js +83 -0
  105. package/src/remake.js +738 -0
  106. package/src/repaint-archive.js +53 -0
  107. package/src/repaint-delivery.js +783 -0
  108. package/src/repaint-export.js +63 -0
  109. package/src/repaint-graph.js +214 -0
  110. package/src/repaint-history.js +267 -0
  111. package/src/repaint-materials.js +601 -0
  112. package/src/repaint-reverse.js +614 -0
  113. package/src/repaint-sequence.js +132 -0
  114. package/src/repaint-settings.js +576 -0
  115. package/src/repaint-video.js +798 -0
  116. package/src/repaint.js +326 -0
  117. package/src/resource.js +110 -0
  118. package/src/review.js +435 -0
  119. package/src/service.js +85 -0
  120. package/src/store.js +100 -0
  121. package/src/video-clip.js +381 -0
  122. package/src/video-frame.js +296 -0
  123. package/src/workflows.js +214 -0
@@ -0,0 +1,254 @@
1
+ import crypto from 'node:crypto'
2
+ import path from 'node:path'
3
+ import { atomic, json, root, safeName } from './store.js'
4
+ import { materialFolders, materialGet, scopedCanvas } from './materials.js'
5
+ import { readSource } from './generation-input.js'
6
+ import { segment } from './workflows.js'
7
+
8
+ const text = (value) => String(value ?? '').trim()
9
+ const digest = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
10
+ const errorOf = (code, message, diagnostic) =>
11
+ Object.assign(new Error(message), { code, ...(diagnostic ? { diagnostic } : {}) })
12
+ export const materialSavePath = (profile, operation) =>
13
+ path.join(root(), 'material-saves', safeName(profile), safeName(operation) + '.json')
14
+ const positiveId = (value) => {
15
+ if (!/^[1-9]\d*$/.test(text(value)) || !Number.isSafeInteger(Number(value)))
16
+ throw new Error('任务和目录 ID 必须为正安全整数')
17
+ return Number(value)
18
+ }
19
+ function normalizeInput(value) {
20
+ if (
21
+ !value ||
22
+ Array.isArray(value) ||
23
+ typeof value !== 'object' ||
24
+ Object.keys(value).some(
25
+ (key) =>
26
+ !['doc_id', 'node_id', 'space', 'team_uid', 'folder_id', 'name', 'description'].includes(
27
+ key
28
+ )
29
+ )
30
+ )
31
+ throw new Error('保存素材输入无效或包含未接入字段')
32
+ if (!text(value.doc_id) || !text(value.node_id)) throw new Error('缺少来源 doc_id/node_id')
33
+ const input = { ...value, folder_id: positiveId(value.folder_id) }
34
+ if (
35
+ value.name !== undefined &&
36
+ (typeof value.name !== 'string' || !value.name.trim() || value.name.trim().length > 200)
37
+ )
38
+ throw new Error('素材名称须为 1–200 个字符')
39
+ if (value.description !== undefined && typeof value.description !== 'string')
40
+ throw new Error('素材说明必须为字符串')
41
+ if (value.name !== undefined) input.name = value.name.trim()
42
+ return input
43
+ }
44
+ async function identity(client) {
45
+ const me = await client.raw('/auth/me')
46
+ const owner = text(me.uid ?? me.id)
47
+ if (!owner) throw new Error('账号信息缺少 UID')
48
+ return owner
49
+ }
50
+ async function sourceContext(client, input) {
51
+ const doc = await scopedCanvas(client, input.doc_id, input.space, input.team_uid)
52
+ const node = doc.nodes.find((candidate) => candidate.id === input.node_id)
53
+ if (
54
+ !node ||
55
+ node.data?.extra?.repaint ||
56
+ node.data?.extra?.video_remake ||
57
+ node.data?.extra?.clip_redraw
58
+ )
59
+ throw new Error('保存来源须为已有普通图片/视频节点或素材节点;派生流程暂未接入')
60
+ const source = readSource(node),
61
+ data = node.data || {}
62
+ if (
63
+ !['image', 'video'].includes(source.mediaType) ||
64
+ !source.url ||
65
+ source.uploading ||
66
+ source.uploadError
67
+ )
68
+ throw new Error('来源没有已完成上传的图片/视频输出')
69
+ const taskId = positiveId(data.lastTaskId || data.last_task_id || data.runtime?.taskId || data.sourceTaskId || data.extra?.source_task_id)
70
+ const task = await client.raw('/canvas/generations/' + segment(taskId))
71
+ const refs = [
72
+ data.resultFileRef,
73
+ data.fileRef,
74
+ data.file_ref,
75
+ data.extra?.file_ref,
76
+ data.result?.file_ref,
77
+ data.node_output?.file_ref
78
+ ]
79
+ .map(text)
80
+ .filter(Boolean)
81
+ if (new Set(refs).size !== 1 || !task.result?.file_ref || refs[0] !== text(task.result.file_ref))
82
+ throw new Error('当前素材引用与任务结果不一致;不保存旧任务或其他媒体')
83
+ if (
84
+ String(task.task_id ?? task.id) !== String(taskId) ||
85
+ task.doc_id !== doc.id ||
86
+ task.segment_type !== source.mediaType ||
87
+ task.status !== 'completed'
88
+ )
89
+ throw new Error('素材来源与同画布已完成图片/视频任务不匹配')
90
+ const folders = await materialFolders(client, { space: input.space, teamUid: input.team_uid })
91
+ if (!folders.items?.some((folder) => String(folder.id) === String(input.folder_id)))
92
+ throw new Error('目标目录不属于确认的素材空间')
93
+ const name = input.name ?? text(data.title || data.name)
94
+ if (name.length > 200) throw new Error('素材名称超过 200 个字符,请明确名称')
95
+ const body = {
96
+ task_id: taskId,
97
+ folder_id: input.folder_id,
98
+ ...(name ? { name } : {}),
99
+ ...(input.description ? { description: input.description } : {})
100
+ }
101
+ return { body, mediaType: source.mediaType, sourceRef: refs[0] }
102
+ }
103
+ const summary = (record) => ({
104
+ operation: record.operation,
105
+ state: record.state,
106
+ task_id: record.body.task_id,
107
+ folder_id: record.body.folder_id,
108
+ asset_id: record.assetId || null,
109
+ ...(record.asset ? { asset: record.asset } : {}),
110
+ ...(record.lastFailure ? { last_failure: record.lastFailure } : {})
111
+ })
112
+ async function bound(client, profile, operation) {
113
+ const record = await json(materialSavePath(profile, operation), null)
114
+ if (!record) throw new Error('未找到素材保存 operation')
115
+ if (record.baseUrl !== client.baseUrl || record.owner !== (await identity(client)))
116
+ throw new Error('素材保存 operation 的服务或账号不匹配')
117
+ if (record.bodyHash !== digest(record.body)) throw new Error('素材保存请求摘要不匹配,停止恢复')
118
+ return record
119
+ }
120
+ function validateAsset(asset, record) {
121
+ positiveId(asset?.id)
122
+ if (
123
+ String(asset.folder_id) !== String(record.body.folder_id) ||
124
+ !text(asset.mime_type)
125
+ .toLowerCase()
126
+ .startsWith(record.mediaType + '/') ||
127
+ !asset.url ||
128
+ !asset.file_ref
129
+ )
130
+ throw errorOf(
131
+ 'MATERIAL_SAVE_UNKNOWN',
132
+ '保存响应素材的目录/类型/引用不完整;查询原 operation,不新建保存'
133
+ )
134
+ return asset
135
+ }
136
+ async function verifySaved(client, profile, record) {
137
+ const asset = validateAsset(
138
+ await materialGet(client, {
139
+ space: record.input.space,
140
+ teamUid: record.input.team_uid,
141
+ assetId: record.assetId
142
+ }),
143
+ record
144
+ )
145
+ record.state = 'saved'
146
+ record.asset = asset
147
+ await atomic(materialSavePath(profile, record.operation), record)
148
+ return summary(record)
149
+ }
150
+ async function execute(client, profile, record) {
151
+ record.state = 'submitting'
152
+ await atomic(materialSavePath(profile, record.operation), record)
153
+ let response
154
+ try {
155
+ response = await client.raw(
156
+ '/material-space/assets/save-from-canvas',
157
+ { method: 'POST', body: record.body },
158
+ { retry: false }
159
+ )
160
+ } catch (error) {
161
+ const rejected =
162
+ [401, 403, 404, 422].includes(error.status) ||
163
+ [1000, 2000, 2001, 3001].includes(error.apiCode)
164
+ record.state = rejected ? 'rejected' : 'unknown'
165
+ record.lastFailure = {
166
+ code: rejected ? 'MATERIAL_SAVE_REJECTED' : 'MATERIAL_SAVE_UNKNOWN',
167
+ ...(Number.isInteger(error.status) ? { http_status: error.status } : {}),
168
+ ...(Number.isFinite(error.apiCode) ? { api_code: error.apiCode } : {}),
169
+ ...(error.requestId ? { request_id: error.requestId } : {})
170
+ }
171
+ await atomic(materialSavePath(profile, record.operation), record)
172
+ throw errorOf(
173
+ record.lastFailure.code,
174
+ rejected
175
+ ? '素材保存请求被拒绝;先核对原目录和接口错误,不自动另建保存'
176
+ : '素材保存结果未确认;先查目标目录与原 operation,仅明确恢复时沿用原幂等键',
177
+ record.lastFailure
178
+ )
179
+ }
180
+ try {
181
+ validateAsset(response, record)
182
+ } catch (error) {
183
+ record.state = 'unknown'
184
+ await atomic(materialSavePath(profile, record.operation), record)
185
+ throw error
186
+ }
187
+ record.assetId = response.id
188
+ record.state = 'verifying'
189
+ await atomic(materialSavePath(profile, record.operation), record)
190
+ // A failed read-back never causes another POST. The returned ID is already durable.
191
+ return verifySaved(client, profile, record)
192
+ }
193
+ export async function startMaterialSave(client, profile, value, operation, confirmed = false) {
194
+ const input = normalizeInput(value),
195
+ file = materialSavePath(profile, operation)
196
+ const existing = await json(file, null)
197
+ if (existing) {
198
+ const record = await bound(client, profile, operation)
199
+ if (digest(input) !== digest(record.input)) throw new Error('同一保存 operation 不可变更输入')
200
+ // Repeated start is a query, even with --yes. Recovery is explicit.
201
+ return getMaterialSave(client, profile, operation)
202
+ }
203
+ const owner = await identity(client),
204
+ context = await sourceContext(client, input)
205
+ if (!confirmed)
206
+ return {
207
+ operation,
208
+ submitted: false,
209
+ generation_submitted: false,
210
+ wallet: { space: input.space, team_uid: input.team_uid || null },
211
+ ...context.body,
212
+ media_type: context.mediaType,
213
+ next: '核对来源与目录后,加 --yes 直接保存原结果;不下载重传或提交生成'
214
+ }
215
+ const body = { ...context.body, idempotency_key: 'cli-material-' + crypto.randomUUID() }
216
+ const record = {
217
+ operation,
218
+ owner,
219
+ baseUrl: client.baseUrl,
220
+ input,
221
+ body,
222
+ bodyHash: digest(body),
223
+ mediaType: context.mediaType,
224
+ sourceRef: context.sourceRef,
225
+ state: 'prepared',
226
+ createdAt: new Date().toISOString()
227
+ }
228
+ await atomic(file, record)
229
+ return execute(client, profile, record)
230
+ }
231
+ export async function getMaterialSave(client, profile, operation) {
232
+ const record = await bound(client, profile, operation)
233
+ return record.assetId
234
+ ? verifySaved(client, profile, record)
235
+ : {
236
+ ...summary(record),
237
+ next: '先列目标目录核对;需要恢复未知提交时,save-recover 原 operation --yes 沿用原请求和幂等键'
238
+ }
239
+ }
240
+ export async function recoverMaterialSave(client, profile, operation, confirmed = false) {
241
+ const record = await bound(client, profile, operation)
242
+ if (record.assetId) return verifySaved(client, profile, record)
243
+ if (!confirmed || record.state === 'rejected') return getMaterialSave(client, profile, operation)
244
+ const current = await sourceContext(client, record.input)
245
+ const { idempotency_key, ...original } = record.body
246
+ if (
247
+ !idempotency_key ||
248
+ digest(current.body) !== digest(original) ||
249
+ current.sourceRef !== record.sourceRef ||
250
+ current.mediaType !== record.mediaType
251
+ )
252
+ throw new Error('保存期间源素材、任务、目录或参数已变化;不重发原请求')
253
+ return execute(client, profile, record)
254
+ }
@@ -0,0 +1,147 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { segment, uploadParts } from './workflows.js'
4
+ import { spaceContext, writeNode } from './canvas.js'
5
+
6
+ export function materialScope(space, teamUid) {
7
+ spaceContext({ space, teamUid })
8
+ return { scope: space, ...(space === 'team' ? { team_uid: teamUid } : {}) }
9
+ }
10
+ const query = (endpoint, params) =>
11
+ endpoint + '?' + new URLSearchParams(Object.entries(params).filter(([, v]) => v !== undefined))
12
+ export async function scopedCanvas(client, docId, space, teamUid) {
13
+ materialScope(space, teamUid)
14
+ const doc = await client.raw(`/canvas/docs/${segment(docId)}`)
15
+ if (doc.id !== docId || String(doc.team_uid || '') !== String(space === 'team' ? teamUid : ''))
16
+ throw new Error('画布归属与确认空间不一致')
17
+ if (!['owner', 'editor'].includes(doc.role)) throw new Error('画布不可编辑')
18
+ return doc
19
+ }
20
+ export async function materialFolders(client, { space, teamUid }) {
21
+ return client.raw(query('/material-space/folders', materialScope(space, teamUid)))
22
+ }
23
+ export async function materialList(
24
+ client,
25
+ { space, teamUid, folderId, keyword, limit = 50, offset = 0 }
26
+ ) {
27
+ if (
28
+ !Number.isSafeInteger(limit) ||
29
+ limit < 1 ||
30
+ limit > 200 ||
31
+ !Number.isSafeInteger(offset) ||
32
+ offset < 0
33
+ )
34
+ throw new Error('素材分页参数无效')
35
+ return client.raw(
36
+ query('/material-space/assets', {
37
+ ...materialScope(space, teamUid),
38
+ folder_id: folderId,
39
+ keyword,
40
+ limit,
41
+ offset
42
+ })
43
+ )
44
+ }
45
+ export async function materialGet(client, { space, teamUid, assetId }) {
46
+ if (!/^[1-9][0-9]*$/.test(String(assetId))) throw new Error('asset ID 必须为正整数')
47
+ const folders = await materialFolders(client, { space, teamUid })
48
+ const asset = await client.raw(`/material-space/assets/${segment(assetId)}`)
49
+ if (!folders.items?.some((f) => String(f.id) === String(asset.folder_id)))
50
+ throw new Error('素材不属于确认的素材空间')
51
+ return asset
52
+ }
53
+ export async function materialUpload(client, { space, teamUid, folderId, file, mime, name }) {
54
+ const folders = await materialFolders(client, { space, teamUid })
55
+ if (!folders.items?.some((f) => String(f.id) === String(folderId)))
56
+ throw new Error('目标文件夹不属于确认空间')
57
+ const type = String(mime || '').split('/')[0]
58
+ if (!['image', 'audio', 'video'].includes(type))
59
+ throw new Error('素材 MIME 必须为 image/audio/video')
60
+ const limits = await client.raw('/material-space/upload-limits')
61
+ const handle = await fs.open(file, 'r')
62
+ try {
63
+ const stat = await handle.stat()
64
+ if (
65
+ !stat.isFile() ||
66
+ stat.size <= 0 ||
67
+ !Number.isFinite(limits.max_size_bytes) ||
68
+ stat.size > limits.max_size_bytes ||
69
+ !limits.allowed_mime_prefixes?.some((p) => mime.startsWith(p))
70
+ )
71
+ throw new Error('素材不符合实际上传限制')
72
+ const session = await client.raw(
73
+ '/uploads/sessions',
74
+ {
75
+ method: 'POST',
76
+ body: {
77
+ media_type: type,
78
+ filename: path.basename(file),
79
+ file_size: stat.size,
80
+ mime_type: mime,
81
+ purpose: 'material',
82
+ scope_id: String(folderId)
83
+ }
84
+ },
85
+ { retry: false }
86
+ )
87
+ const parts = await uploadParts(client, handle, stat, session, mime)
88
+ await client.raw(
89
+ `/uploads/sessions/${segment(session.upload_id)}/complete`,
90
+ {
91
+ method: 'POST',
92
+ body: { object_key: session.object_key, expected_parts: parts.length, parts }
93
+ },
94
+ { retry: false }
95
+ )
96
+ const body = new URLSearchParams({
97
+ folder_id: String(folderId),
98
+ upload_id: session.upload_id,
99
+ name: name || path.basename(file)
100
+ })
101
+ // No automatic replay: after an ambiguous response, inspect the same folder first.
102
+ return await client.raw(
103
+ '/material-space/assets/upload',
104
+ { method: 'POST', body },
105
+ { retry: false }
106
+ )
107
+ } finally {
108
+ await handle.close()
109
+ }
110
+ }
111
+ export async function materialUse(client, { space, teamUid, assetId, docId, nodeId, position }) {
112
+ if (
113
+ position !== undefined &&
114
+ (!position ||
115
+ typeof position !== 'object' ||
116
+ !Number.isFinite(position.x) ||
117
+ !Number.isFinite(position.y))
118
+ )
119
+ throw new Error('position.x/y 必须为有限数字')
120
+ const doc = await scopedCanvas(client, docId, space, teamUid)
121
+ const asset = await materialGet(client, { space, teamUid, assetId })
122
+ const type = String(asset.mime_type || '').split('/')[0]
123
+ if (!['image', 'video', 'audio'].includes(type) || !asset.url || !asset.file_ref)
124
+ throw new Error('素材缺少可用媒体引用')
125
+ if (doc.nodes.some((n) => n.id === nodeId))
126
+ throw new Error('目标节点已存在;先读回核对,不重复导入')
127
+ return writeNode(client, {
128
+ docId,
129
+ nodeId,
130
+ type: `${type}_upload`,
131
+ expectedSpace: space,
132
+ expectedTeamUid: teamUid,
133
+ patch: {
134
+ position: position || { x: 120, y: 130 },
135
+ data: {
136
+ title: asset.name,
137
+ url: asset.url,
138
+ file_ref: asset.file_ref,
139
+ mime_type: asset.mime_type,
140
+ file_name: asset.name,
141
+ width: asset.width,
142
+ height: asset.height,
143
+ extra: { file_ref: asset.file_ref, material_asset_id: asset.id }
144
+ }
145
+ }
146
+ })
147
+ }
@@ -0,0 +1,73 @@
1
+ import path from 'node:path'
2
+ import {fileURLToPath} from 'node:url'
3
+ import crypto from 'node:crypto'
4
+ import {spawn} from 'node:child_process'
5
+ import {atomic,json,safeName} from './store.js'
6
+ import {baseUrl} from './api.js'
7
+ import {mattingPath,validateMattingBackground} from './matting.js'
8
+
9
+ export const mattingWorkerPath=(profile,operation,id)=>path.join(path.dirname(mattingPath(profile,operation)),'background-'+safeName(id)+'.json')
10
+ const alive=pid=>{
11
+ if(!Number.isSafeInteger(pid)||pid<1)return false
12
+ try{process.kill(pid,0);return true}catch(error){return error.code==='EPERM'?null:false}
13
+ }
14
+ const active=new Set(['launching','waiting_lock','running'])
15
+ export async function mattingBackgroundStatus(store,operation){
16
+ const r=await json(mattingPath(store.name,operation),null)
17
+ if(!r||r.schema!==1||r.profile!==store.name||r.operation!==operation)throw Error('未找到原抠图operation')
18
+ const config=await store.config()
19
+ if(config.baseUrl&&baseUrl(config.baseUrl)!==r.baseUrl)throw Error('抠图operation服务与当前profile不匹配')
20
+ const worker=r.backgroundId?await json(mattingWorkerPath(store.name,operation,r.backgroundId),null):null
21
+ if(worker&&(worker.worker_id!==r.backgroundId||worker.profile!==store.name||worker.operation!==operation||worker.baseUrl!==r.baseUrl))throw Error('后台记录绑定不匹配')
22
+ const running=worker?.pid?alive(worker.pid):false
23
+ const interrupted=!!worker&&active.has(worker.status)&&worker.pid&&running===false
24
+ const launchUnconfirmed=!!worker&&active.has(worker.status)&&!worker.pid&&Date.now()-Date.parse(worker.updated_at)>=30000
25
+ const state=interrupted&&r.attempted&&!r.receipt&&r.state==='submitting'?'submission_unknown':r.state
26
+ const since=worker?.started_at||worker?.created_at
27
+ const elapsed=since?Math.max(0,(worker?.finished_at?Date.parse(worker.finished_at):Date.now())-Date.parse(since)):null
28
+ return {operation,profile:store.name,base_url:r.baseUrl,state,doc_id:r.input.doc_id,source_node_id:r.input.node_id,
29
+ ...(r.transport?{transport:r.transport,job_id:r.jobId||null,job_status:r.jobStatus||null}:{}),
30
+ ...(r.diagnostic?{diagnostic:r.diagnostic}:{}),
31
+ result_node_id:r.nodeId,credit_cost:r.cost??null,output:r.output||null,...(r.image?{image:r.image}:{}),
32
+ worker_status:interrupted?'interrupted':launchUnconfirmed?'launch_unconfirmed':worker?.status||'not_started',worker_alive:worker?.pid?running:false,
33
+ ...(worker?.pid?{worker_pid:worker.pid}:{}),worker_error:worker?.error||null,
34
+ phase:state==='submitting'?'waiting_response_including_queue':state,progress:null,elapsed_ms:elapsed,
35
+ updated_at:worker?.updated_at||null,poll_after_ms:worker&&active.has(worker.status)&&!interrupted&&!launchUnconfirmed?3000:null,
36
+ processing_request_attempted:!!r.attempted,known_result_reference:!!r.receipt?.['x-result-ref'],
37
+ background_auto_saves_canvas:false,read_only:true,local_only:true,account_verified_online:false,
38
+ canvas_written:false,processing_submitted:false,library_saved:false,
39
+ next:r.transport==='async-job'?(r.jobId?'此为本地快照;matting-get联网查询原job,成功后matting-recover --yes回填':'尚未取得job_id;保留原operation及诊断,请服务端核对原request_id,不重发'):state==='result_ready'?'原PNG已缓存;确认画布关闭后matting-recover --yes回填':interrupted?
40
+ '后台进程已退出;保留原operation,不自动重发。若profile锁残留,确认原进程退出后按现有锁规则处理':
41
+ worker&&active.has(worker.status)?'约3秒后查询同一operation;排队时间计入等待,不按3秒判失败':'保留原operation查询/恢复,不重做抠图'}
42
+ }
43
+ export async function launchMattingBackground(client,store,operation,confirmed=false,{requestTimeout=180000}={}){
44
+ const existing=await json(mattingPath(store.name,operation),null)
45
+ if(!existing)throw Error('后台抠图须先用image matting预览同一operation')
46
+ if(existing.baseUrl!==client.baseUrl)throw Error('后台抠图服务不匹配')
47
+ if(existing.attempted)return mattingBackgroundStatus(store,operation)
48
+ if(existing.backgroundId){
49
+ const old=await json(mattingWorkerPath(store.name,operation,existing.backgroundId),null)
50
+ if(old&&active.has(old.status)&&(old.pid?alive(old.pid)!==false:Date.now()-Date.parse(old.updated_at)<30000))return mattingBackgroundStatus(store,operation)
51
+ }
52
+ if(!confirmed)return {...await mattingBackgroundStatus(store,operation),next:'确认本次免费处理后matting-start 原operation --yes;后台仅缓存,不写画布'}
53
+ if(!Number.isSafeInteger(requestTimeout)||requestTimeout<1||requestTimeout>1800000)throw Error('后台request-timeout须为1–1800000毫秒')
54
+ const r=await validateMattingBackground(client,store.name,operation),id=crypto.randomUUID()
55
+ const record={schema:1,profile:store.name,operation,baseUrl:client.baseUrl,worker_id:id,status:'launching',
56
+ created_at:new Date().toISOString(),updated_at:new Date().toISOString(),request_timeout:requestTimeout}
57
+ r.backgroundId=id
58
+ await atomic(mattingPath(store.name,operation),r)
59
+ await atomic(mattingWorkerPath(store.name,operation,id),record)
60
+ try{
61
+ const child=spawn(process.execPath,[fileURLToPath(new URL('./matting-worker.js',import.meta.url)),store.name,operation,id,String(requestTimeout)],
62
+ {detached:true,windowsHide:true,stdio:'ignore',cwd:path.dirname(fileURLToPath(import.meta.url))})
63
+ await new Promise((resolve,reject)=>{child.once('spawn',resolve);child.once('error',reject)})
64
+ child.unref()
65
+ // Only the worker writes its status after spawn; a parent write could overwrite
66
+ // an already completed response when the service responds immediately.
67
+ return {...await mattingBackgroundStatus(store,operation),worker_pid:child.pid,worker_alive:alive(child.pid),background_started:true,read_only:false,local_only:false,next:'启动命令可以退出;约3秒后matting-status查询原operation。后台只缓存,回填另用原recover --yes'}
68
+ }catch(error){
69
+ record.status='launch_failed';record.error={code:'WORKER_LAUNCH_FAILED'};record.updated_at=new Date().toISOString()
70
+ await atomic(mattingWorkerPath(store.name,operation,id),record)
71
+ throw Object.assign(Error('抠图后台未能启动;查询原operation,不创建新操作',{cause:error}),{code:'MATTING_BACKGROUND_FAILED'})
72
+ }
73
+ }
@@ -0,0 +1,63 @@
1
+ import {readBoundMatting,mattingSummary,persistMatting,validateMattingCache,saveMattingResult,cacheMattingReference} from './matting.js'
2
+ import {multipartText} from './multipart.js'
3
+ import {materialScope} from './materials.js'
4
+
5
+ const states=new Set(['queued','processing','succeeded','failed','cancelled'])
6
+ const jobId=value=>typeof value==='string'&&/^[A-Za-z0-9_-]{1,200}$/.test(value)?value:null
7
+ const ref=value=>typeof value==='string'&&/^fr1_[A-Za-z0-9_-]+={0,2}$/.test(value)
8
+ const pending=(message,diagnostic)=>Object.assign(Error(message),{code:'MATTING_SUBMISSION_PENDING',...(diagnostic?{diagnostic}:{})})
9
+
10
+ export async function submitMattingJob(client,profile,operation,confirmed=false){
11
+ const {r,c}=await readBoundMatting(client,profile,operation)
12
+ if(r.attempted)return r.transport==='async-job'?recoverMattingJob(client,profile,operation,false):mattingSummary(r)
13
+ if(r.backgroundId)throw Error('原operation已交给本地后台,不改为异步任务提交')
14
+ if(r.transport&&r.transport!=='async-job')throw Error('原抠图传输类型不匹配')
15
+ if(r.state!=='prepared'||c.doc.version!==r.version)throw Error('异步提交须先预览并匹配当前画布版本')
16
+ if(!confirmed)return {...mattingSummary(r),next:'确认本次免费处理后matting-submit 原operation --yes;只入队,不自动写画布'}
17
+ r.transport='async-job';r.attempted=true;r.state='submitting';await persistMatting(r)
18
+ let response
19
+ try{
20
+ response=await client.raw('/matting/jobs',{method:'POST',...multipartText({file_ref:r.binding.file_ref,model:'general',...materialScope(r.input.space,r.input.team_uid)})},{retry:false,jsonMetadata:true})
21
+ }catch(error){
22
+ r.state=[2001,2010,2011,2012,2205].includes(error.apiCode)||[400,401,403,404,422].includes(error.status)?'rejected':'submission_unknown'
23
+ r.diagnostic={...(error.status?{http_status:error.status}:{}),...(error.apiCode?{api_code:error.apiCode}:{}),...(error.requestId?{request_id:error.requestId}:{})}
24
+ await persistMatting(r);throw pending('异步抠图未取得已确认提交;查询原operation,不重POST',r.diagnostic)
25
+ }
26
+ // Keep any usable ID before interpreting status; an unexpected receipt must
27
+ // never cause the same operation to submit again.
28
+ r.jobId=jobId(response.data?.job_id)
29
+ r.state=r.jobId?'job_submitted':'submission_unknown'
30
+ await persistMatting(r)
31
+ if(response.status!==202||!r.jobId)throw pending('异步提交响应不是202或缺少有效job_id;保留原operation及已知ID,不重发')
32
+ return {...mattingSummary(r),poll_after_ms:3000,next:'任务已入队,可退出CLI;matting-get查询原job,完成后原matting-recover --yes回填'}
33
+ }
34
+
35
+ export async function recoverMattingJob(client,profile,operation,confirmed=false){
36
+ const {r,c}=await readBoundMatting(client,profile,operation)
37
+ if(r.transport!=='async-job')throw Error('原operation不是异步抠图任务')
38
+ if(r.saveAttempted)return saveMattingResult(client,r,c,false)
39
+ if(!r.jobId)return {...mattingSummary(r),next:'提交未取得job_id,无法按ID查回;不自动重提、不认领最新同源任务'}
40
+ const data=await client.raw('/matting/jobs/'+encodeURIComponent(r.jobId))
41
+ if(!data||!states.has(data.status)||(data.job_id!==undefined&&data.job_id!==r.jobId)){
42
+ r.state='job_response_unverified';await persistMatting(r);throw Error('抠图job响应身份/状态不匹配,保留原job_id')
43
+ }
44
+ r.jobStatus=data.status;r.jobCheckedAt=new Date().toISOString()
45
+ if(typeof data.credit_cost==='number'&&Number.isFinite(data.credit_cost))r.reportedCreditCost=data.credit_cost
46
+ r.state='job_'+data.status
47
+ await persistMatting(r)
48
+ if(data.status!=='succeeded')return {...mattingSummary(r),poll_after_ms:['queued','processing'].includes(data.status)?3000:null}
49
+ if(!ref(data.result_ref)||![0,'0'].includes(data.credit_cost)||(data.source_ref!==undefined&&data.source_ref!==r.binding.file_ref)){
50
+ r.state='job_result_unverified';await persistMatting(r);throw Error('抠图job结果引用/免费费用/来源不匹配,不下载落图或重做')
51
+ }
52
+ if(r.receipt&&r.receipt['x-result-ref']!==data.result_ref){r.state='job_result_unverified';await persistMatting(r);throw Error('原抠图job结果引用变化,保留缓存并停止落图')}
53
+ r.cost=0;r.receiptType='job-query'
54
+ r.receipt={'x-result-ref':data.result_ref,'x-source-ref':r.binding.file_ref,'x-credit-cost':'0'}
55
+ r.state='job_succeeded';await persistMatting(r)
56
+ // result_url may be an external delivery URL. Retrieve through the trusted
57
+ // service's original file-reference route, anonymously and without API tokens.
58
+ if(!confirmed)return {...mattingSummary(r),next:'原job已成功;确认画布关闭后matting-recover 原operation --yes取回PNG并保存'}
59
+ await cacheMattingReference(client,r,true)
60
+ try{await validateMattingCache(r)}catch(error){r.state='result_unverified';await persistMatting(r);throw error}
61
+ const current=await readBoundMatting(client,profile,operation)
62
+ return saveMattingResult(client,r,current.c,true)
63
+ }
@@ -0,0 +1,49 @@
1
+ import {setTimeout as sleep} from 'node:timers/promises'
2
+ import {Store,json,atomic} from './store.js'
3
+ import {ApiClient} from './api.js'
4
+ import {startMatting,mattingPath} from './matting.js'
5
+ import {mattingWorkerPath} from './matting-background.js'
6
+
7
+ // Internal entry point. Arguments identify the already bound local operation;
8
+ // credentials are loaded from its protected profile, never passed in argv/logs.
9
+ async function run(){
10
+ const [profile,operation,id,timeoutText]=process.argv.slice(2)
11
+ const timeout=Number(timeoutText),store=new Store(profile),file=mattingWorkerPath(profile,operation,id)
12
+ if(!Number.isSafeInteger(timeout)||timeout<1||timeout>1800000)throw Error('Invalid worker timeout')
13
+ const worker=await json(file,null),first=await json(mattingPath(profile,operation),null)
14
+ if(!worker||worker.worker_id!==id||worker.profile!==profile||worker.operation!==operation||first?.backgroundId!==id)return
15
+ let updates=Promise.resolve(),timer,unlock
16
+ const write=patch=>{
17
+ Object.assign(worker,patch,{updated_at:new Date().toISOString()})
18
+ const snapshot=structuredClone(worker)
19
+ updates=updates.catch(()=>{}).then(()=>atomic(file,snapshot));return updates
20
+ }
21
+ try{
22
+ await write({pid:process.pid,status:'waiting_lock',started_at:new Date().toISOString()})
23
+ timer=setInterval(()=>{void write({heartbeat_at:new Date().toISOString()}).catch(()=>{})},1000)
24
+ const deadline=Date.now()+30000
25
+ while(!unlock){
26
+ try{unlock=await store.lock()}catch(error){
27
+ if(!error.message.startsWith('该 profile 正被使用')||Date.now()>=deadline)throw error
28
+ await sleep(100)
29
+ }
30
+ }
31
+ const record=await json(mattingPath(profile,operation),null)
32
+ if(record?.backgroundId!==id||record.attempted||record.state!=='prepared')throw Object.assign(Error('Worker reservation changed'),{code:'WORKER_RESERVATION_CHANGED'})
33
+ const client=new ApiClient(await store.load(),store,timeout)
34
+ if(client.baseUrl!==worker.baseUrl||record.baseUrl!==worker.baseUrl)throw Object.assign(Error('Worker service changed'),{code:'WORKER_BINDING_CHANGED'})
35
+ await write({status:'running'})
36
+ const result=await startMatting(client,profile,record.input,operation,true,{backgroundId:id,deferCanvas:true})
37
+ clearInterval(timer);timer=null;await updates
38
+ await write({status:'finished',finished_at:new Date().toISOString(),result_state:result.state})
39
+ }catch(error){
40
+ if(timer){clearInterval(timer);timer=null};await updates.catch(()=>{})
41
+ await write({status:'failed',finished_at:new Date().toISOString(),error:{code:typeof error.code==='string'?error.code:'WORKER_FAILED',
42
+ ...(Number.isInteger(error.status)?{http_status:error.status}:{}),...(Number.isInteger(error.apiCode)?{api_code:error.apiCode}:{})}})
43
+ process.exitCode=1
44
+ }finally{
45
+ if(timer)clearInterval(timer)
46
+ if(unlock)await unlock()
47
+ }
48
+ }
49
+ run().catch(()=>{process.exitCode=1})