@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
package/src/review.js
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import crypto from 'node:crypto'
|
|
3
|
+
import { setTimeout as sleep } from 'node:timers/promises'
|
|
4
|
+
import { atomic, json, root, safeName } from './store.js'
|
|
5
|
+
import { cancellation } from './api.js'
|
|
6
|
+
import { scopedCanvas } from './materials.js'
|
|
7
|
+
import { segment } from './workflows.js'
|
|
8
|
+
import { collectGenerationSources, readSource } from './generation-input.js'
|
|
9
|
+
import { sameImageReferences, sameVideoReferences } from './media.js'
|
|
10
|
+
|
|
11
|
+
const pending = new Set(['queued', 'pending', 'processing', 'reviewing', 'in_review', 'submitted'])
|
|
12
|
+
const digest = (value) => crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
13
|
+
const string = (value) => String(value ?? '').trim()
|
|
14
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
15
|
+
export const reviewPath = (profile, operation) =>
|
|
16
|
+
path.join(root(), 'reviews', safeName(profile), safeName(operation) + '.json')
|
|
17
|
+
function inputOf(input) {
|
|
18
|
+
if (!input || Array.isArray(input) || typeof input !== 'object')
|
|
19
|
+
throw new Error('审核输入必须为 JSON 对象')
|
|
20
|
+
if (
|
|
21
|
+
Object.keys(input).some(
|
|
22
|
+
(key) => !['doc_id', 'node_id', 'space', 'team_uid', 'mode'].includes(key)
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
throw new Error('审核输入包含未接入字段;不支持 force 重审')
|
|
26
|
+
if (!string(input.doc_id) || !string(input.node_id)) throw new Error('审核缺少 doc_id/node_id')
|
|
27
|
+
if (!['current', 'upstream'].includes(input.mode))
|
|
28
|
+
throw new Error('审核 mode 必须为 current 或 upstream')
|
|
29
|
+
return { ...input }
|
|
30
|
+
}
|
|
31
|
+
function fileRef(data, url) {
|
|
32
|
+
const known = [
|
|
33
|
+
data.resultFileRef,
|
|
34
|
+
data.fileRef,
|
|
35
|
+
data.file_ref,
|
|
36
|
+
data.extra?.file_ref,
|
|
37
|
+
data.result?.file_ref,
|
|
38
|
+
data.node_output?.file_ref
|
|
39
|
+
]
|
|
40
|
+
.map(string)
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
if (new Set(known).size > 1) throw new Error('素材引用字段不一致;请先核对当前媒体与任务')
|
|
43
|
+
const direct =
|
|
44
|
+
data.resultFileRef ||
|
|
45
|
+
data.fileRef ||
|
|
46
|
+
data.file_ref ||
|
|
47
|
+
data.extra?.file_ref ||
|
|
48
|
+
data.result?.file_ref ||
|
|
49
|
+
data.node_output?.file_ref
|
|
50
|
+
if (direct) return string(direct)
|
|
51
|
+
const match = string(url).match(/\/api\/v1\/files\/(fr1_[^/?#]+)/)
|
|
52
|
+
try {
|
|
53
|
+
return match ? decodeURIComponent(match[1]) : ''
|
|
54
|
+
} catch {
|
|
55
|
+
return ''
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function taskIdOf(data) {
|
|
59
|
+
const id =
|
|
60
|
+
data.sourceTaskId ||
|
|
61
|
+
data.extra?.source_task_id ||
|
|
62
|
+
data.last_task_id ||
|
|
63
|
+
data.lastTaskId ||
|
|
64
|
+
data.result?.task_id ||
|
|
65
|
+
data.runtime?.taskId
|
|
66
|
+
if (!/^[1-9]\d*$/.test(string(id)) || !Number.isSafeInteger(Number(id)))
|
|
67
|
+
throw new Error('审核素材缺少有效的真实任务 ID;不能伪造任务或将素材库 ID 当任务 ID')
|
|
68
|
+
return Number(id)
|
|
69
|
+
}
|
|
70
|
+
function guardImage(data, task) {
|
|
71
|
+
const width = Number(data.imageWidth || data.width || task.result?.width) || 0
|
|
72
|
+
const height = Number(data.imageHeight || data.height || task.result?.height) || 0
|
|
73
|
+
const match = string(data.aspectRatio || data.aspect_ratio || task.result?.aspect_ratio).match(
|
|
74
|
+
/^([\d.]+)\s*[:/x×]\s*([\d.]+)$/i
|
|
75
|
+
)
|
|
76
|
+
const ratio = width && height ? width / height : match ? Number(match[1]) / Number(match[2]) : 0
|
|
77
|
+
if (width && (width < 300 || width > 6000)) throw new Error('审核图片宽度须为 300–6000 像素')
|
|
78
|
+
if (!(ratio >= 0.4 && ratio <= 2.5))
|
|
79
|
+
throw new Error('审核图片宽高比须在 0.4–2.5;缺少尺寸时先核对原图')
|
|
80
|
+
}
|
|
81
|
+
async function context(client, input) {
|
|
82
|
+
const doc = await scopedCanvas(client, input.doc_id, input.space, input.team_uid)
|
|
83
|
+
const target = doc.nodes.find((node) => node.id === input.node_id)
|
|
84
|
+
if (!target) throw new Error('审核目标节点不存在')
|
|
85
|
+
if (
|
|
86
|
+
target.data?.extra?.repaint ||
|
|
87
|
+
target.data?.extra?.video_remake ||
|
|
88
|
+
target.data?.extra?.clip_redraw
|
|
89
|
+
)
|
|
90
|
+
throw new Error('当前审核只支持普通节点;派生流程须按专用路线接入')
|
|
91
|
+
const sources =
|
|
92
|
+
input.mode === 'current'
|
|
93
|
+
? [readSource(target)]
|
|
94
|
+
: collectGenerationSources(doc, target, ['image', 'video']).all
|
|
95
|
+
const items = []
|
|
96
|
+
for (const source of sources.filter((source) => ['image', 'video'].includes(source.mediaType))) {
|
|
97
|
+
const node = doc.nodes.find((node) => node.id === source.id)
|
|
98
|
+
const reference = source
|
|
99
|
+
const data = node?.data || reference
|
|
100
|
+
const taskId = taskIdOf(data)
|
|
101
|
+
const ref = fileRef(data, source.url)
|
|
102
|
+
if (!ref || source.uploading || source.uploadError)
|
|
103
|
+
throw new Error(`审核素材 ${source.id} 缺少已上传的媒体引用`)
|
|
104
|
+
const task = await client.raw(`/canvas/generations/${segment(taskId)}`)
|
|
105
|
+
if (
|
|
106
|
+
String(task.task_id ?? task.id) !== String(taskId) ||
|
|
107
|
+
task.doc_id !== input.doc_id ||
|
|
108
|
+
task.segment_type !== source.mediaType ||
|
|
109
|
+
task.status !== 'completed'
|
|
110
|
+
)
|
|
111
|
+
throw new Error('审核任务与素材/当前画布不匹配或尚未完成;跨画布素材审核暂未接入')
|
|
112
|
+
if (!task.result?.file_ref || !(await (source.mediaType === 'image' ? sameImageReferences : sameVideoReferences)(client, ref, task.result.file_ref)))
|
|
113
|
+
throw new Error('审核素材引用与原任务结果不一致;先核对素材,不将旧审核套用到新图/视频')
|
|
114
|
+
if (source.mediaType === 'image') guardImage(data, task)
|
|
115
|
+
const old = items.find((item) => item.taskId === taskId)
|
|
116
|
+
if (old) {
|
|
117
|
+
if (old.ref !== ref || old.mediaType !== source.mediaType)
|
|
118
|
+
throw new Error('同一审核任务对应不同素材引用')
|
|
119
|
+
old.sourceIds.push(source.id)
|
|
120
|
+
} else
|
|
121
|
+
items.push({
|
|
122
|
+
taskId,
|
|
123
|
+
ref,
|
|
124
|
+
mediaType: source.mediaType,
|
|
125
|
+
sourceIds: [source.id],
|
|
126
|
+
url: source.url
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
if (!items.length) throw new Error('没有可审核的图片/视频素材')
|
|
130
|
+
return { doc, target, items }
|
|
131
|
+
}
|
|
132
|
+
function normalizeReview(value, item, docId) {
|
|
133
|
+
if (
|
|
134
|
+
!value ||
|
|
135
|
+
String(value.task_id) !== String(item.taskId) ||
|
|
136
|
+
value.doc_id !== docId ||
|
|
137
|
+
!string(value.status)
|
|
138
|
+
)
|
|
139
|
+
throw new Error('审核响应的任务/画布绑定不完整')
|
|
140
|
+
const status = string(value.status).toLowerCase()
|
|
141
|
+
if (!pending.has(status) && !['active', 'failed', 'rejected'].includes(status))
|
|
142
|
+
throw new Error('审核返回未知状态,停止并查询原任务')
|
|
143
|
+
if (
|
|
144
|
+
status === 'active' &&
|
|
145
|
+
(!string(value.asset_uri).startsWith('asset://') || !string(value.submission_id))
|
|
146
|
+
)
|
|
147
|
+
throw new Error('已通过审核的响应缺少真实资产或提交关联')
|
|
148
|
+
return {
|
|
149
|
+
status,
|
|
150
|
+
taskId: item.taskId,
|
|
151
|
+
docId,
|
|
152
|
+
sourceTaskId: item.taskId,
|
|
153
|
+
mediaType: item.mediaType,
|
|
154
|
+
...(item.mediaType === 'image' ? { imageRef: item.ref } : { mediaRef: item.ref }),
|
|
155
|
+
assetUri: value.asset_uri || null,
|
|
156
|
+
assetType: value.asset_type || null,
|
|
157
|
+
groupId: value.group_id || null,
|
|
158
|
+
submissionId: value.submission_id || null,
|
|
159
|
+
volcengineAssetId: value.volcengine_asset_id || null,
|
|
160
|
+
moderation: value.moderation || null,
|
|
161
|
+
updatedAt: value.updated_at || null,
|
|
162
|
+
celeryTaskId: value.celery_task_id || null
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const notFound = (error) => error.status === 404 || error.apiCode === 3001
|
|
166
|
+
async function readReview(client, item, docId) {
|
|
167
|
+
try {
|
|
168
|
+
return normalizeReview(
|
|
169
|
+
await client.raw(`/canvas/seedance/reviews/${segment(item.taskId)}`),
|
|
170
|
+
item,
|
|
171
|
+
docId
|
|
172
|
+
)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (notFound(error)) return null
|
|
175
|
+
throw error
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const result = (record) => ({
|
|
179
|
+
operation: record.operation,
|
|
180
|
+
state: record.state,
|
|
181
|
+
canvas_synced: record.canvasSynced === true,
|
|
182
|
+
items: record.items.map((item) => ({
|
|
183
|
+
task_id: item.taskId,
|
|
184
|
+
source_node_ids: item.sourceIds,
|
|
185
|
+
status: item.review?.status || item.state,
|
|
186
|
+
...(item.lastFailure ? { last_failure: item.lastFailure } : {}),
|
|
187
|
+
review: item.review || null
|
|
188
|
+
}))
|
|
189
|
+
})
|
|
190
|
+
async function identity(client) {
|
|
191
|
+
const me = await client.raw('/auth/me'),
|
|
192
|
+
owner = string(me.uid ?? me.id)
|
|
193
|
+
if (!owner) throw new Error('审核账号响应缺少 UID')
|
|
194
|
+
return owner
|
|
195
|
+
}
|
|
196
|
+
async function bound(client, profile, operation) {
|
|
197
|
+
const record = await json(reviewPath(profile, operation), null)
|
|
198
|
+
if (!record) throw new Error('未找到审核 operation')
|
|
199
|
+
if (record.baseUrl !== client.baseUrl || record.owner !== (await identity(client)))
|
|
200
|
+
throw new Error('审核 operation 的服务或账号不匹配')
|
|
201
|
+
const current = await context(client, record.input)
|
|
202
|
+
if (digest(current.items) !== record.contextHash)
|
|
203
|
+
throw new Error('审核期间源素材、任务或引用已变化;停止,不修改原审核操作')
|
|
204
|
+
return { record, current }
|
|
205
|
+
}
|
|
206
|
+
function updateState(record) {
|
|
207
|
+
const statuses = record.items.map((item) => item.review?.status)
|
|
208
|
+
record.state = statuses.every((status) => status === 'active')
|
|
209
|
+
? 'active'
|
|
210
|
+
: statuses.some((status) => ['failed', 'rejected'].includes(status))
|
|
211
|
+
? 'failed'
|
|
212
|
+
: 'pending'
|
|
213
|
+
}
|
|
214
|
+
async function synchronize(client, record, doc) {
|
|
215
|
+
const original = digest([doc.nodes, doc.meta])
|
|
216
|
+
const patches = record.items
|
|
217
|
+
.filter((item) => item.review)
|
|
218
|
+
.map((item) => ({ ...item, ids: new Set(item.sourceIds) }))
|
|
219
|
+
const patchRefs = (refs) =>
|
|
220
|
+
Array.isArray(refs)
|
|
221
|
+
? refs.map((ref) => {
|
|
222
|
+
const match = patches.find(
|
|
223
|
+
(item) => item.ids.has(string(ref.id)) && string(ref.url) === item.url
|
|
224
|
+
)
|
|
225
|
+
return match
|
|
226
|
+
? { ...ref, seedanceReview: match.review, reviewStatus: match.review.status }
|
|
227
|
+
: ref
|
|
228
|
+
})
|
|
229
|
+
: refs
|
|
230
|
+
for (const node of doc.nodes) {
|
|
231
|
+
const source = readSource(node)
|
|
232
|
+
const match = patches.find(
|
|
233
|
+
(item) =>
|
|
234
|
+
(item.ids.has(node.id) && source.url === item.url) ||
|
|
235
|
+
(source.url === item.url &&
|
|
236
|
+
source.mediaType === item.mediaType &&
|
|
237
|
+
fileRef(node.data || {}, source.url) === item.ref)
|
|
238
|
+
)
|
|
239
|
+
node.data ||= {}
|
|
240
|
+
if (match) {
|
|
241
|
+
node.data.extra = { ...node.data.extra, seedance_review: match.review }
|
|
242
|
+
if (node.data.seedanceReview) node.data.seedanceReview = match.review
|
|
243
|
+
if (node.data.seedance_review) node.data.seedance_review = match.review
|
|
244
|
+
}
|
|
245
|
+
if (node.data.references) node.data.references = patchRefs(node.data.references)
|
|
246
|
+
if (node.data.extra?.references)
|
|
247
|
+
node.data.extra = { ...node.data.extra, references: patchRefs(node.data.extra.references) }
|
|
248
|
+
if (node.id === record.input.node_id && record.input.mode === 'upstream')
|
|
249
|
+
node.data.extra = {
|
|
250
|
+
...node.data.extra,
|
|
251
|
+
seedance_material_reviews: {
|
|
252
|
+
...node.data.extra?.seedance_material_reviews,
|
|
253
|
+
...Object.fromEntries(
|
|
254
|
+
patches.flatMap((item) => item.sourceIds.map((id) => [id, item.review.status]))
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (doc.meta?.node_references)
|
|
260
|
+
doc.meta.node_references = Object.fromEntries(
|
|
261
|
+
Object.entries(doc.meta.node_references).map(([id, refs]) => [id, patchRefs(refs)])
|
|
262
|
+
)
|
|
263
|
+
if (digest([doc.nodes, doc.meta]) === original) return
|
|
264
|
+
if (!Number.isSafeInteger(doc.version) || !Array.isArray(doc.edges) || !doc.viewport || !doc.meta)
|
|
265
|
+
throw new Error('审核保存的画布快照不完整')
|
|
266
|
+
let saved
|
|
267
|
+
try {
|
|
268
|
+
saved = await client.raw(
|
|
269
|
+
`/canvas/docs/${segment(doc.id)}`,
|
|
270
|
+
{
|
|
271
|
+
method: 'PUT',
|
|
272
|
+
body: {
|
|
273
|
+
version: doc.version,
|
|
274
|
+
name: doc.name,
|
|
275
|
+
viewport: doc.viewport,
|
|
276
|
+
nodes: doc.nodes,
|
|
277
|
+
edges: doc.edges,
|
|
278
|
+
meta: doc.meta
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
{ retry: false }
|
|
282
|
+
)
|
|
283
|
+
} catch (error) {
|
|
284
|
+
throw fail(
|
|
285
|
+
'REVIEW_SYNC_PENDING',
|
|
286
|
+
error.status === 409 || error.apiCode === 3002
|
|
287
|
+
? '审核状态已保存本地,画布版本冲突;核对后 recover 原审核,不重新审核'
|
|
288
|
+
: '审核已查询,画布保存结果未确认;读回后 recover 原审核,不重新提交'
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
if (saved.id !== doc.id || saved.version <= doc.version || !Array.isArray(saved.nodes))
|
|
292
|
+
throw fail('REVIEW_SYNC_PENDING', '审核画布保存响应不完整;核对后恢复原 operation')
|
|
293
|
+
for (const item of patches)
|
|
294
|
+
for (const id of item.sourceIds) {
|
|
295
|
+
if (!doc.nodes.some((node) => node.id === id)) continue // A persistent reference may have no live node.
|
|
296
|
+
const node = saved.nodes.find((node) => node.id === id)
|
|
297
|
+
if (
|
|
298
|
+
node?.data?.extra?.seedance_review?.submissionId !== item.review.submissionId ||
|
|
299
|
+
node?.data?.extra?.seedance_review?.status !== item.review.status
|
|
300
|
+
)
|
|
301
|
+
throw fail('REVIEW_SYNC_PENDING', '审核状态未在保存响应中核实;不重新审核,先读回画布')
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
export async function startReview(client, profile, input, operation, confirmed) {
|
|
305
|
+
input = inputOf(input)
|
|
306
|
+
const file = reviewPath(profile, operation),
|
|
307
|
+
existing = await json(file, null)
|
|
308
|
+
const owner = await identity(client)
|
|
309
|
+
const current = await context(client, input)
|
|
310
|
+
if (existing) {
|
|
311
|
+
if (
|
|
312
|
+
existing.baseUrl !== client.baseUrl ||
|
|
313
|
+
existing.owner !== owner ||
|
|
314
|
+
digest(existing.input) !== digest(input) ||
|
|
315
|
+
existing.contextHash !== digest(current.items)
|
|
316
|
+
)
|
|
317
|
+
throw new Error('审核 operation 已绑定其他输入/服务/账号;不能更改原操作')
|
|
318
|
+
return getReview(client, profile, operation, confirmed) // No POST replay, even when --yes is repeated.
|
|
319
|
+
}
|
|
320
|
+
const preview = {
|
|
321
|
+
operation,
|
|
322
|
+
submitted: false,
|
|
323
|
+
mode: input.mode,
|
|
324
|
+
wallet: { space: input.space, team_uid: input.team_uid || null },
|
|
325
|
+
generation_submitted: false,
|
|
326
|
+
items: current.items.map((item) => ({
|
|
327
|
+
task_id: item.taskId,
|
|
328
|
+
media_type: item.mediaType,
|
|
329
|
+
source_node_ids: item.sourceIds
|
|
330
|
+
})),
|
|
331
|
+
next: '核对本次审核素材后,加 --yes 提交审核;这不授权视频生成'
|
|
332
|
+
}
|
|
333
|
+
if (!confirmed) return preview
|
|
334
|
+
const record = {
|
|
335
|
+
operation,
|
|
336
|
+
owner,
|
|
337
|
+
baseUrl: client.baseUrl,
|
|
338
|
+
input,
|
|
339
|
+
contextHash: digest(current.items),
|
|
340
|
+
items: current.items.map((item) => ({ ...item, state: 'prepared' })),
|
|
341
|
+
state: 'prepared',
|
|
342
|
+
createdAt: new Date().toISOString()
|
|
343
|
+
}
|
|
344
|
+
await atomic(file, record)
|
|
345
|
+
for (const item of record.items) {
|
|
346
|
+
item.review = await readReview(client, item, input.doc_id)
|
|
347
|
+
if (item.review) {
|
|
348
|
+
item.state = item.review.status
|
|
349
|
+
await atomic(file, record)
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
item.state = 'submitting'
|
|
353
|
+
await atomic(file, record)
|
|
354
|
+
try {
|
|
355
|
+
const response = await client.raw(
|
|
356
|
+
'/canvas/seedance/reviews',
|
|
357
|
+
{
|
|
358
|
+
method: 'POST',
|
|
359
|
+
body: {
|
|
360
|
+
task_id: item.taskId,
|
|
361
|
+
[item.mediaType === 'image' ? 'image_ref' : 'media_ref']: item.ref,
|
|
362
|
+
force: false
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
{ retry: false }
|
|
366
|
+
)
|
|
367
|
+
item.review = normalizeReview(response, item, input.doc_id)
|
|
368
|
+
item.state = item.review.status
|
|
369
|
+
} catch (error) {
|
|
370
|
+
// Generic business/dispatch errors can occur after the server persisted a review.
|
|
371
|
+
const rejected =
|
|
372
|
+
[401, 403, 404, 422].includes(error.status) ||
|
|
373
|
+
[1000, 2000, 2001, 3001].includes(error.apiCode)
|
|
374
|
+
item.state = rejected ? 'rejected' : 'unknown'
|
|
375
|
+
record.state = item.state
|
|
376
|
+
item.lastFailure = {
|
|
377
|
+
...(Number.isInteger(error.status) ? { http_status: error.status } : {}),
|
|
378
|
+
...(Number.isFinite(error.apiCode) ? { api_code: error.apiCode } : {}),
|
|
379
|
+
...(error.requestId ? { request_id: error.requestId } : {}),
|
|
380
|
+
code: rejected ? 'REVIEW_REQUEST_REJECTED' : 'REVIEW_SUBMISSION_UNKNOWN'
|
|
381
|
+
}
|
|
382
|
+
await atomic(file, record)
|
|
383
|
+
throw Object.assign(
|
|
384
|
+
fail(
|
|
385
|
+
item.lastFailure.code,
|
|
386
|
+
rejected
|
|
387
|
+
? `素材审核请求被接口拒绝;核对 HTTP/业务错误码,仅查询原 operation ${operation},不自动重提`
|
|
388
|
+
: `审核提交结果未确认;仅 recover ${operation} 查询原任务,不重发审核或生成`
|
|
389
|
+
),
|
|
390
|
+
{ diagnostic: item.lastFailure }
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
await atomic(file, record)
|
|
394
|
+
}
|
|
395
|
+
return recoverReview(client, profile, operation)
|
|
396
|
+
}
|
|
397
|
+
export async function getReview(client, profile, operation, sync = false) {
|
|
398
|
+
const { record, current } = await bound(client, profile, operation)
|
|
399
|
+
let absent = false
|
|
400
|
+
for (const item of record.items) {
|
|
401
|
+
const review = await readReview(client, item, record.input.doc_id)
|
|
402
|
+
if (review) {
|
|
403
|
+
item.review = review
|
|
404
|
+
item.state = review.status
|
|
405
|
+
} else {
|
|
406
|
+
absent = true
|
|
407
|
+
item.state = item.lastFailure?.code === 'REVIEW_REQUEST_REJECTED' ? 'rejected' : 'unknown'
|
|
408
|
+
item.review = null
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
updateState(record)
|
|
412
|
+
if (absent)
|
|
413
|
+
record.state = record.items.some((item) => item.state === 'rejected') ? 'rejected' : 'unknown'
|
|
414
|
+
record.canvasSynced = false
|
|
415
|
+
await atomic(reviewPath(profile, operation), record)
|
|
416
|
+
if (sync && !absent) {
|
|
417
|
+
await synchronize(client, record, current.doc)
|
|
418
|
+
record.canvasSynced = true
|
|
419
|
+
await atomic(reviewPath(profile, operation), record)
|
|
420
|
+
}
|
|
421
|
+
return result(record)
|
|
422
|
+
}
|
|
423
|
+
export const recoverReview = (client, profile, operation) =>
|
|
424
|
+
getReview(client, profile, operation, true)
|
|
425
|
+
export async function waitReview(client, profile, operation, interval = 3000, timeout = 180000) {
|
|
426
|
+
const deadline = Date.now() + timeout
|
|
427
|
+
while (Date.now() < deadline) {
|
|
428
|
+
const current = await recoverReview(client, profile, operation)
|
|
429
|
+
if (['active', 'failed', 'rejected', 'unknown'].includes(current.state)) return current
|
|
430
|
+
await sleep(Math.min(interval, Math.max(1, deadline - Date.now())), undefined, {
|
|
431
|
+
signal: cancellation.signal
|
|
432
|
+
})
|
|
433
|
+
}
|
|
434
|
+
throw fail('WAIT_TIMEOUT', '审核等待超时;继续 wait/recover 原审核 operation,不重新提交')
|
|
435
|
+
}
|
package/src/service.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { request } from './api.js'
|
|
2
|
+
|
|
3
|
+
// Public same-origin probes do not carry profile credentials or create signing sessions.
|
|
4
|
+
export async function serviceStatus(client, timeout = Math.min(client.timeout || 8000, 8000)) {
|
|
5
|
+
const probe = async (endpoint) => {
|
|
6
|
+
try {
|
|
7
|
+
const response = await request(client.baseUrl + endpoint, {}, timeout)
|
|
8
|
+
const payload = await response.json().catch(() => null)
|
|
9
|
+
const data = payload?.data ?? payload
|
|
10
|
+
const state = ['ok', 'alive', 'ready', 'not_ready'].includes(data?.status)
|
|
11
|
+
? data.status
|
|
12
|
+
: null
|
|
13
|
+
const checks = Object.fromEntries(
|
|
14
|
+
['database', 'redis'].flatMap((key) =>
|
|
15
|
+
['ok', 'error'].includes(data?.checks?.[key]) ? [[key, data.checks[key]]] : []
|
|
16
|
+
)
|
|
17
|
+
)
|
|
18
|
+
return {
|
|
19
|
+
endpoint,
|
|
20
|
+
http_status: response.status,
|
|
21
|
+
state,
|
|
22
|
+
...(Object.keys(checks).length ? { checks } : {}),
|
|
23
|
+
...(Number.isFinite(payload?.code) ? { api_code: payload.code } : {})
|
|
24
|
+
}
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error.name === 'AbortError' && error.message?.includes('aborted'))
|
|
27
|
+
return { endpoint, error: 'cancelled' }
|
|
28
|
+
return {
|
|
29
|
+
endpoint,
|
|
30
|
+
error: ['TimeoutError', 'AbortError'].includes(error.name) ? 'timeout' : 'connection_failed'
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const [live, ready] = await Promise.all(['/health/live', '/health/ready'].map(probe))
|
|
35
|
+
const status =
|
|
36
|
+
ready.http_status === 200 &&
|
|
37
|
+
ready.state === 'ready' &&
|
|
38
|
+
(ready.api_code === undefined || ready.api_code === 0) &&
|
|
39
|
+
ready.checks?.database === 'ok' &&
|
|
40
|
+
ready.checks?.redis === 'ok'
|
|
41
|
+
? 'ready'
|
|
42
|
+
: ready.state === 'not_ready' || Object.values(ready.checks || {}).includes('error')
|
|
43
|
+
? 'not_ready'
|
|
44
|
+
: [live, ready].every((p) => p.error || [502, 503, 504].includes(p.http_status))
|
|
45
|
+
? 'unreachable'
|
|
46
|
+
: 'unknown'
|
|
47
|
+
return {
|
|
48
|
+
status,
|
|
49
|
+
checked_at: new Date().toISOString(),
|
|
50
|
+
live,
|
|
51
|
+
readiness: ready,
|
|
52
|
+
scope: 'api_database_redis',
|
|
53
|
+
generation_submitted: false,
|
|
54
|
+
next:
|
|
55
|
+
status === 'ready'
|
|
56
|
+
? '当前后端就绪;不代表先前没有断线,也不验证对象存储、队列或具体业务接口'
|
|
57
|
+
: '先处理连接/代理/后端依赖或探针兼容,再查询原操作;不自动重发业务写入'
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function requireServiceReady(client) {
|
|
61
|
+
const service = await serviceStatus(client)
|
|
62
|
+
if (service.status === 'ready') return service
|
|
63
|
+
throw Object.assign(new Error('后端连接或就绪状态未确认;已停止业务提交,先查询原操作'), {
|
|
64
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
65
|
+
service
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
export async function diagnoseServiceError(client, error) {
|
|
69
|
+
if (error.service) return error.service
|
|
70
|
+
const diagnostic = error.diagnostic || {}
|
|
71
|
+
const http = error.status ?? diagnostic.http_status ?? error.cause?.status
|
|
72
|
+
const api = error.apiCode ?? diagnostic.api_code ?? error.cause?.apiCode
|
|
73
|
+
if (
|
|
74
|
+
!(
|
|
75
|
+
http >= 500 ||
|
|
76
|
+
[3000, 5000].includes(api) ||
|
|
77
|
+
['TypeError', 'TimeoutError', 'AbortError'].includes(error.name) ||
|
|
78
|
+
['TypeError', 'TimeoutError', 'AbortError'].includes(error.cause?.name) ||
|
|
79
|
+
(error.code === 'API_ERROR' && !api && http === 200) ||
|
|
80
|
+
/UNKNOWN|SYNC_PENDING/.test(error.code || '')
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return null
|
|
84
|
+
return serviceStatus(client)
|
|
85
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import crypto from 'node:crypto'
|
|
5
|
+
import { spawn } from 'node:child_process'
|
|
6
|
+
import {setTimeout as sleep} from 'node:timers/promises'
|
|
7
|
+
|
|
8
|
+
export const root = () => path.join(process.env.APPDATA || path.join(os.homedir(), '.config'), 'uzoo-anime-cli')
|
|
9
|
+
export function safeName(name) {
|
|
10
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(name)) throw new Error('名称只能包含字母、数字、下划线和短横线(1–64 位)')
|
|
11
|
+
return name
|
|
12
|
+
}
|
|
13
|
+
export async function json(file, fallback) {
|
|
14
|
+
try { return JSON.parse(await fs.readFile(file, 'utf8')) } catch (e) { if (e.code === 'ENOENT') return fallback; throw e }
|
|
15
|
+
}
|
|
16
|
+
export async function atomic(file, value) {
|
|
17
|
+
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
18
|
+
const tmp = `${file}.${crypto.randomUUID()}.tmp`
|
|
19
|
+
try {
|
|
20
|
+
const handle = await fs.open(tmp, 'wx', 0o600)
|
|
21
|
+
try { await handle.writeFile(JSON.stringify(value, null, 2) + '\n'); await handle.sync() } finally { await handle.close() }
|
|
22
|
+
// Windows scanners/readers can briefly prevent replacing a closed file.
|
|
23
|
+
// Retry only this already-flushed local rename; never remove the old file
|
|
24
|
+
// or replay the business request that produced this journal entry.
|
|
25
|
+
const delays=[25,50,100,200,400,800]
|
|
26
|
+
for(let attempt=0;;attempt++){
|
|
27
|
+
try{await fs.rename(tmp,file);break}catch(error){
|
|
28
|
+
if(process.platform!=='win32'||!['EPERM','EACCES','EBUSY'].includes(error.code)||attempt>=delays.length)throw error
|
|
29
|
+
await sleep(delays[attempt])
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
} finally { await fs.rm(tmp, { force: true }) }
|
|
33
|
+
}
|
|
34
|
+
function helper(exe, args, input = '') {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const child = spawn(exe, args, { windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
|
|
37
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error('系统凭据服务超时')) }, 15000)
|
|
38
|
+
let output = ''
|
|
39
|
+
child.stdout.on('data', c => { output += c })
|
|
40
|
+
child.stderr.resume()
|
|
41
|
+
child.stdin.on('error', () => {})
|
|
42
|
+
child.on('error', () => { clearTimeout(timer); reject(new Error('系统凭据服务不可用;请安装/启用系统密钥服务')) })
|
|
43
|
+
child.on('close', code => { clearTimeout(timer); code === 0 ? resolve(output.trim()) : reject(new Error('系统凭据服务失败;不会回退为明文存储')) })
|
|
44
|
+
child.stdin.end(input)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
// Windows CurrentUser DPAPI: ciphertext can only be decrypted in this user's OS context.
|
|
48
|
+
async function dpapi(mode, input) {
|
|
49
|
+
const method = mode === 'protect' ? 'Protect' : 'Unprotect'
|
|
50
|
+
return helper('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
51
|
+
`Add-Type -AssemblyName System.Security; $bytes=[Convert]::FromBase64String([Console]::In.ReadToEnd()); [Convert]::ToBase64String([Security.Cryptography.ProtectedData]::${method}($bytes,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser))`], input)
|
|
52
|
+
}
|
|
53
|
+
export class Store {
|
|
54
|
+
constructor(name) { this.name = safeName(name); this.configFile = path.join(root(), `${name}.json`); this.secretFile = path.join(root(), `${name}.dpapi`); this.service = `uzoo-anime-cli:${name}` }
|
|
55
|
+
async config() {
|
|
56
|
+
const saved = await json(this.configFile, {})
|
|
57
|
+
return { ...(this.name === 'uzooai-online' ? { baseUrl: 'https://uzooai.com/api/v1' } : {}), ...saved }
|
|
58
|
+
}
|
|
59
|
+
async secrets(value) {
|
|
60
|
+
if (process.platform === 'win32') {
|
|
61
|
+
if (value !== undefined) { await atomic(this.secretFile, { ciphertext: await dpapi('protect', Buffer.from(JSON.stringify(value)).toString('base64')) }); return }
|
|
62
|
+
const saved = await json(this.secretFile, null)
|
|
63
|
+
return saved ? JSON.parse(Buffer.from(await dpapi('unprotect', saved.ciphertext), 'base64').toString('utf8')) : {}
|
|
64
|
+
}
|
|
65
|
+
if (process.platform === 'darwin') {
|
|
66
|
+
// macOS security CLI cannot receive add-generic-password secrets via stdin safely.
|
|
67
|
+
throw new Error('此版本尚未实现 macOS 安全凭据适配器;不会保存明文凭据')
|
|
68
|
+
}
|
|
69
|
+
if (value !== undefined) { await helper('secret-tool', ['store', '--label=Uzoo Anime CLI', 'service', this.service], JSON.stringify(value)); return }
|
|
70
|
+
const result = await helper('secret-tool', ['lookup', 'service', this.service])
|
|
71
|
+
return result ? JSON.parse(result) : {}
|
|
72
|
+
}
|
|
73
|
+
async load() {
|
|
74
|
+
const config = await this.config()
|
|
75
|
+
const legacy = ['accessToken', 'refreshToken', 'cookie'].some(k => config[k])
|
|
76
|
+
if (legacy) {
|
|
77
|
+
await this.save(config) // migrate before removing plaintext; failure leaves original intact
|
|
78
|
+
return { ...config, profile: this.name }
|
|
79
|
+
}
|
|
80
|
+
return { ...config, ...await this.secrets(), profile: this.name }
|
|
81
|
+
}
|
|
82
|
+
async save(value) {
|
|
83
|
+
const { accessToken = '', refreshToken = '', cookie = '', deviceId = '', baseUrl, authMode = '', refreshRequestId = '' } = value
|
|
84
|
+
// Keep the binding and credentials in one protected atomic record. If a
|
|
85
|
+
// subsequent non-secret config write fails, load() still sees a matching pair.
|
|
86
|
+
await this.secrets({ accessToken, refreshToken, cookie, refreshRequestId, baseUrl, deviceId, authMode })
|
|
87
|
+
await atomic(this.configFile, { baseUrl, deviceId, authMode })
|
|
88
|
+
}
|
|
89
|
+
async clear() {
|
|
90
|
+
if (process.platform === 'win32') await fs.rm(this.secretFile, { force: true })
|
|
91
|
+
else if (process.platform === 'linux') await helper('secret-tool', ['clear', 'service', this.service])
|
|
92
|
+
await fs.rm(this.configFile, { force: true })
|
|
93
|
+
}
|
|
94
|
+
async lock() {
|
|
95
|
+
const file = path.join(root(), `${this.name}.lock`)
|
|
96
|
+
await fs.mkdir(root(), { recursive: true })
|
|
97
|
+
try { await fs.mkdir(file) } catch (e) { if (e.code === 'EEXIST') throw new Error('该 profile 正被使用;若上次进程被强制终止,请确认其已退出后删除对应 .lock 目录'); throw e }
|
|
98
|
+
return () => fs.rmdir(file)
|
|
99
|
+
}
|
|
100
|
+
}
|