@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
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
+ }