@uzooai/anime-ai-cli 0.0.0-stage → 0.6.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.
- package/.agents/skills/uzoo-anime-cli/SKILL.md +89 -0
- package/.agents/skills/uzoo-anime-cli/references/agreement-workflow.md +41 -0
- package/.agents/skills/uzoo-anime-cli/references/canvas-workflow.md +53 -0
- package/.agents/skills/uzoo-anime-cli/references/commands.md +242 -0
- package/.agents/skills/uzoo-anime-cli/references/generation-workflow.md +142 -0
- package/.agents/skills/uzoo-anime-cli/references/image-tools-workflow.md +153 -0
- package/.agents/skills/uzoo-anime-cli/references/interaction-workflow.md +67 -0
- package/.agents/skills/uzoo-anime-cli/references/material-graph-workflow.md +113 -0
- package/.agents/skills/uzoo-anime-cli/references/node-capabilities.md +64 -0
- package/.agents/skills/uzoo-anime-cli/references/node-workflow.md +90 -0
- package/.agents/skills/uzoo-anime-cli/references/remake-workflow.md +54 -0
- package/.agents/skills/uzoo-anime-cli/references/repaint-workflow.md +13 -0
- package/.agents/skills/uzoo-anime-cli/references/seedance-review-workflow.md +37 -0
- package/.agents/skills/uzoo-anime-cli/references/video-tools-workflow.md +67 -0
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +46 -3
- package/assets/favicon-uzoo.png +0 -0
- package/assets/logo-uzoo.svg +7 -0
- package/docs/Agent/346/216/245/345/205/245/344/270/216/347/211/210/346/234/254/346/233/264/346/226/260.md +43 -0
- package/examples/matting.json +5 -0
- package/extensions/uzoo-cli-login/manifest.json +9 -0
- package/extensions/uzoo-cli-login/popup.css +1 -0
- package/extensions/uzoo-cli-login/popup.html +14 -0
- package/extensions/uzoo-cli-login/popup.js +65 -0
- package/package.json +53 -6
- package/src/agreement-page.js +62 -0
- package/src/agreement.js +419 -0
- package/src/annotation-media.js +386 -0
- package/src/api.js +168 -0
- package/src/browser-login.js +13 -0
- package/src/callback-page.js +44 -0
- package/src/canvas.js +105 -0
- package/src/cli-authorization.js +122 -0
- package/src/cli.js +464 -0
- package/src/download-name.js +17 -0
- package/src/draft-finalize.js +229 -0
- package/src/generation-graph.js +124 -0
- package/src/generation-input.js +472 -0
- package/src/graph-disconnect.js +89 -0
- package/src/graph.js +431 -0
- package/src/group-edit.js +53 -0
- package/src/group-layout.js +113 -0
- package/src/group-membership.js +125 -0
- package/src/history-add.js +119 -0
- package/src/image-annotation.js +246 -0
- package/src/image-crop.js +283 -0
- package/src/image-matting.js +82 -0
- package/src/image-media.js +89 -0
- package/src/image-output.js +385 -0
- package/src/install.js +237 -0
- package/src/local-output.js +23 -0
- package/src/material-download.js +55 -0
- package/src/material-save.js +254 -0
- package/src/materials.js +147 -0
- package/src/matting-background.js +73 -0
- package/src/matting-jobs.js +63 -0
- package/src/matting-worker.js +49 -0
- package/src/matting.js +170 -0
- package/src/media-copy.js +53 -0
- package/src/media.js +343 -0
- package/src/model-options.js +76 -0
- package/src/multipart.js +28 -0
- package/src/node-copy.js +95 -0
- package/src/node-delete.js +49 -0
- package/src/node-history.js +231 -0
- package/src/normal-login.js +118 -0
- package/src/package-info.js +8 -0
- package/src/png-alpha.js +60 -0
- package/src/protocol.js +83 -0
- package/src/remake.js +738 -0
- package/src/repaint-archive.js +53 -0
- package/src/repaint-delivery.js +783 -0
- package/src/repaint-export.js +63 -0
- package/src/repaint-graph.js +214 -0
- package/src/repaint-history.js +267 -0
- package/src/repaint-materials.js +601 -0
- package/src/repaint-reverse.js +614 -0
- package/src/repaint-sequence.js +132 -0
- package/src/repaint-settings.js +576 -0
- package/src/repaint-video.js +798 -0
- package/src/repaint.js +326 -0
- package/src/resource.js +110 -0
- package/src/review.js +435 -0
- package/src/service.js +85 -0
- package/src/store.js +100 -0
- package/src/video-clip.js +381 -0
- package/src/video-frame.js +296 -0
- package/src/workflows.js +214 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { materialGet } from './materials.js'
|
|
5
|
+
import { requestFile } from './api.js'
|
|
6
|
+
import { exclusiveOutput } from './local-output.js'
|
|
7
|
+
import { downloadName } from './download-name.js'
|
|
8
|
+
|
|
9
|
+
// Material download is a separate permission gate. A URL returned by list/get
|
|
10
|
+
// never substitutes for a rejected download, even if that URL is still valid.
|
|
11
|
+
export async function downloadMaterial(client, { space, teamUid, assetId, output, expectedHash }) {
|
|
12
|
+
if (typeof output !== 'string' || !output.trim()) throw new Error('素材下载必须提供--output FILE')
|
|
13
|
+
if (expectedHash !== undefined && (typeof expectedHash !== 'string' || !/^[a-fA-F0-9]{64}$/.test(expectedHash)))
|
|
14
|
+
throw new Error('--sha256 必须是64位十六进制')
|
|
15
|
+
// Resolve scope through the actual folder list and asset detail before asking
|
|
16
|
+
// the server for a new original-file link. No filename or format override.
|
|
17
|
+
const asset = await materialGet(client, { space, teamUid, assetId })
|
|
18
|
+
if (String(asset.id) !== String(assetId) || !/^(image|video|audio)\//.test(asset.mime_type || '') || !asset.file_ref)
|
|
19
|
+
throw new Error('素材身份、媒体类型或原文件引用不完整,停止下载')
|
|
20
|
+
const major = asset.mime_type.split('/')[0]
|
|
21
|
+
const link = await client.raw('/material-space/assets/' + encodeURIComponent(String(assetId)) + '/download',
|
|
22
|
+
{}, { retry: false })
|
|
23
|
+
if (!link?.file_ref || typeof link.url !== 'string' || !link.url.trim() ||
|
|
24
|
+
(link.format !== undefined && link.format !== 'original'))
|
|
25
|
+
throw new Error('素材原文件下载接口未返回有效原格式链接;不改用列表地址或转换格式')
|
|
26
|
+
const url = new URL(link.url, new URL(client.baseUrl).origin)
|
|
27
|
+
if (url.searchParams.has('format') && url.searchParams.get('format') !== 'original')
|
|
28
|
+
throw new Error('原文件下载链接包含转换参数;停止,不更改服务端URL或偷偷转换')
|
|
29
|
+
const saved = await exclusiveOutput(output, async temporary => {
|
|
30
|
+
const response = await requestFile(url, client.timeout)
|
|
31
|
+
const mime = (response.headers.get('content-type') || '').split(';')[0].trim().toLowerCase()
|
|
32
|
+
if (mime !== 'application/octet-stream' && !mime.startsWith(major + '/')) {
|
|
33
|
+
await response.body?.cancel()
|
|
34
|
+
throw new Error('素材下载内容类型与原素材不一致;未保存文件')
|
|
35
|
+
}
|
|
36
|
+
const handle = await fs.open(temporary, 'wx', 0o600)
|
|
37
|
+
const hash = crypto.createHash('sha256')
|
|
38
|
+
let bytes = 0
|
|
39
|
+
try {
|
|
40
|
+
for await (const chunk of response.body) {
|
|
41
|
+
await handle.writeFile(chunk)
|
|
42
|
+
hash.update(chunk)
|
|
43
|
+
bytes += chunk.length
|
|
44
|
+
}
|
|
45
|
+
const sha256 = hash.digest('hex'), length = response.headers.get('content-length')
|
|
46
|
+
if (!bytes || (length !== null && !response.headers.get('content-encoding') && bytes !== Number(length)) ||
|
|
47
|
+
(expectedHash && sha256 !== expectedHash.toLowerCase()))
|
|
48
|
+
throw new Error('素材下载文件完整性校验失败;未保存文件')
|
|
49
|
+
return { bytes, sha256, server_filename: downloadName(response.headers, link.filename) }
|
|
50
|
+
} finally { await handle.close() }
|
|
51
|
+
})
|
|
52
|
+
return { ...saved, output: path.resolve(output), asset_id: asset.id, format: 'original',
|
|
53
|
+
...(typeof link.filename === 'string' ? { filename: link.filename } : {}),
|
|
54
|
+
canvas_written: false, generation_submitted: false, processing_submitted: false, library_saved: false }
|
|
55
|
+
}
|
|
@@ -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
|
+
}
|
package/src/materials.js
ADDED
|
@@ -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})
|